DEV Community

Cover image for Build a Tiny Calendar without Flex or useState
Hector Sosa
Hector Sosa

Posted on • Originally published at webscope.io

Build a Tiny Calendar without Flex or useState

Is it possible to build a fully functional calendar component under 7 kB without using CSS Flex or useState? Let's explore that possibility by using Day.js with CSS Grid, TailwindCSS, React and TypeScript. Here's what each of these are bringing to the table today:

  • Day.js — a tiny and fast 2kB alternative API to parse, manipulate and display dates on the web (date-fns is 9.5 times larger).
  • TailwindCSS — skip CSS Flex by learning the fundamentals of CSS Grid the smart way using Tailwind's Grid utility classes.
  • React — extract state logic into reducers by exploring useReducer and skipping additional re-renders using useCallback
  • TypeScript — work smarter and faster by taking advantage of TypeScript's autocompletion and IntelliSense.

There's a lot of ground to cover, so please go through this guide along with the finished component: Calendar GH Repo | Open 'Calendar' in StackBlitz.

Getting started with Day.js

Regardless of the library of choice (if any), here's what we need: (a) current date, (b) current month, and (c) dates for the entire month. Let's take a look how Day.js helps us to get started in defining those initial values:

// USING THE LIBRARY `dayjs`
// dayjs().toDate()      -> Timestamp of today's date / typeof Date
// dayjs().daysinMonth() -> Days in today's month / typeof number

// REUSABLE UTILITY FUNCTIONS FOR `dayjs`
/** Create a date at the start of the day 00:00. */
function today() {
    return dayjs().startOf("day").toDate();
}

/** Create an array of Dates for a given month */
function createMonth(month = today()) {
    return Array.from(
        { length: dayjs(month).daysInMonth() },
        (n, i) => n = dayjs(month).date(i + 1).toDate()
    );
}
Enter fullscreen mode Exit fullscreen mode

These functions will help us clearly define initial values for our calendar with very little code. Once we have these values defined, we are ready to build our first Calendar grid to display each of those dates. The initial values are kept separately for our reducer function to use when we introduce our state logic into our component.

const initialValues = {
    selectedDate: today(),
    currentMonth: today(),
};

export default function Calendar() {
    const { selectedDate, currentMonth } = initialValues;
    const currentMonthDates = createMonth(currentMonth);
    return (
        <div>
            {currentMonthDates.map((date) => (
                <div key={date.toString()}>
                    {date.toString()}
                </div>
            ))}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Building our Calendar using CSS Grid and TailwindCSS

Using TailwindCSS we can apply and define our Grid property with the utility class grid grid-cols-7, then we can map over our array of dates and create a button for each of them for our users to interact with. By applying the CSS property grid-template-columns: repeat(7, minmax(0, 1fr)); using grid-cols-7, we are explicitly defining the columns and allocation of our columns for all the rows of content to follow.

We also have an array named firstDayOfMonth that contains more Tailwind utility classes. We used this array to define a given utility class for our first item (set conditionally using index === 0) and start the calendar on the correct day of the week (i.e. Monday, Tuesday, etc.).

For any given date (i.e. 1st of October 2022), Day.js can tell us which day of the week that date falls in. For example, the 1st of October 2022 falls on a Saturday, so it's the 7th day of the week (based on a Sunday to Saturday calendar week), calling dayjs(date).day() will return 7 accessing the right utility class to display our calendar.

export default function Calendar() {
    // ...
    const firstDayOfMonth = [
        "col-start-1",
        "col-start-2",
        "col-start-3",
        "col-start-4",
        "col-start-5",
        "col-start-6",
        "col-start-7",
    ];
    return (
        <div className="grid grid-cols-7">
            {currentMonthDates.map((date, index) => (
                <div
                    className={index === 0 ? firstDayOfMonth[dayjs(date).day()] : ""}
                    key={date.toString()}
                >
                    <button>{dayjs(date).format("D")}</button>
                </div>
            ))}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Using Reducers to Manage State

Now we need to think how to further reduce complexity, keeping all of our calendar logic in a single easy-to-access place using reducers.

Components with many state updates spread across many event handlers can get overwhelming. For these cases, you can consolidate all the state update logic outside your component in a single function, called a reducer.

Reducers are a great way to cut down on code when many event handlers modify state in a similar or related way (i.e. when updating month, we also need to update the days of the month). It also helps you cleanly separate your state logic and improve readability to easily understand what happened on each update.

We need to (1) write a reducer function (which will process all of our actions), (2) use the reducer (function and initial values) in our component, and (3) set dispatch actions to update our component

// REDUCER FUNCTION OUTSIDE COMPONENT
/** Manages state for selected date and current month  */
function reducer(state, action) {
    switch (action.type) {
        case "SELECT_DATE": {
            return {
                ...state,
                selectedDate: action.value,
            };
        }
        case "UPDATE_MONTH": {
            return {
                ...state,
                currentMonth: action.value,
            };
        }
    }
}

// USING OUR REDUCER IN COMPONENT
export default function Calendar() {
    const [ state, dispatch ] = useReducer(reducer, initialValues);
    // ...
    return (
        // ...
    );
}

// DISPATCHING AN ACTION WITHIN COMPONENT
/** Performs calculations and dispatches reducer actions to update state */
function handleDispatch(action) {
    const { type, value } = action;
    switch (type) {
        case "SELECT_DATE": {
            dispatch({ type, value });
            // TODO: Return component's value
            // setValue: value;
            break;
        }
        case "UPDATE_MONTH": {
            const updatedMonth = dayjs(currentMonth)
                .add(value, "month")
                .toDate();
            dispatch({ type, value: updatedMonth });
            break;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Avoiding Additional Renders

When you optimize rending performance, you will sometimes need to cache the functions that you pass to child components.

Once our calendar is looking good and behaving the way we want it to, we need to figure out a way for our Calendar to return a Date value for our application. To avoid re-rendering our component, we can use a combination of the ref property and React's useCallback.

React uses ref as a reserved property on built-in primitives, where it stores DOM nodes once a component is rendered/mounted. However, the ref's type declaration type Ref<T> = RefCallback<T> | RefObject<T> | null not only allows a ref object into it, but also a callback function. This allows us to cache a function definition (using both ref and useCallback) without having to use useEffect. Let's implement a callback ref for our calendar component:

export default function Calendar({value, setValue}) {
    /** Cached function to update component's state at load */
    const callbackRef = useCallback(() => {
        setValue(selectedDate);
    }, []);
    /** Performs calculations and dispatches reducer actions to update state */
    function handleDispatch(action) {
        const { type, value } = action;
        switch (type) {
            case "SELECT_DATE": {
                dispatch({ type, value });
                setValue(value);
                break;
            }
            // ...
        }
    }
    // ...
    return (
        <div ref={callbackRef}>
            // ...
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

useCallback will return a memoized version of the callback that only changes if one of the inputs has changed.

For a more detailed explanation, Tk's post has a more detailed explanation of this structure: Avoiding useEffect with Callback refs.

Nice things to add

After setting up our data, styling our calendar, and managing its state, here are a couple of nice things that we added to our component:

  • Refactor the component into a "container/presentational" pattern. Use the "container" component to work with the data/state and then pass the data as props to the "presentational" component to display its UI.
  • Use an array and map it to create labels for the days of the week: dayjs().day(index).format("dd").
  • Add a function to generate dynamic TailwindCSS classes using the native Boolean function: function classNames(...classes) { return classes.filter(Boolean).join(" ")}.
  • Use focusable elements <button /> and descriptive labels aria-label to make sure your component is accessible.

Feel free to explore this example using the resources below:

Open in StackBlitz

Originally published: Build a Tiny Calendar without Flex or useState

Top comments (0)