Arsalan Khattak
28 November 2024

Create a Bryntum Calendar using React, tRPC, Express, Sequelize, and SQLite

Bryntum Calendar is a feature-rich and fully customizable JavaScript calendar component with day, week, month, year, and agenda views. It […]

Bryntum Calendar is a feature-rich and fully customizable JavaScript calendar component with day, week, month, year, and agenda views. It easily integrates with any backend or UI framework. In this tutorial, we’ll create a type-safe API using tRPC and show you how to use Bryntum Calendar with it. We’ll use an npm workspaces TypeScript monorepo, with an Express server and React Vite client, as a starting point.

We’ll do the following:

Here’s what we’ll build:

Getting started: Clone the starter app and populate the database

We’ll use the React Bryntum Calendar using tRPC Typecript monorepo starter template as a starting point. The completed-app branch contains the code for the completed tutorial.

The starter template is a monorepo with a React client app and an Express server app. The React client uses Vite, which is a development server and bundler. The server uses Express, Sequelize ORM, and SQLite. The server app has the following code and data for populating a local SQLite database with example data:

For more details on using Bryntum with Sequelize and SQLite, take a look at our guide to using Bryntum Scheduler with Express and SQLite.

Now install the server and client dependencies:

npm install

Change your current directory to the server project folder:

cd server

Once in the server project, create and populate a local SQLite database with the example calendar data in server/src/initialData:

npx tsx ./src/addExampleData.ts

Return to the root directory:

cd ..

Run the local dev servers for the client and the server using the following command:

npm run dev

This runs the client app and the server app simultaneously using the npm package concurrently.

You can access the client app at http://localhost:5173 and the server app at http://localhost:4000. Open the client app in your browser. It should display text reading “App component”, which is rendered by the client/src/App.tsx component.

Creating a Bryntum Calendar component

First, let’s install the Bryntum Calendar component. Start by following this guide to accessing the Bryntum npm repository. Once you’ve logged in to the registry, install the Bryntum Calendar packages.

  npm install @bryntum/calendar@npm:@bryntum/calendar-trial @bryntum/calendar-react --workspace=client
  npm install @bryntum/calendar @bryntum/calendar-react --workspace=client

Now let’s create a basic Bryntum Calendar component.

In the client/src folder, create a components folder. Add a calendar.tsx file to it and add the following lines of code to the file:

import { useEffect, useRef } from 'react';
import { BryntumCalendar } from '@bryntum/calendar-react';
import { calendarProps } from '../config';
function Calendar() {
    const calendarRef = useRef<BryntumCalendar>(null);
    useEffect(() => {
        // Bryntum calendar instance
        const calendar = calendarRef?.current?.instance;
    }, []);
    return (
        <BryntumCalendar
            ref={calendarRef}
            {...calendarProps}
        />
    );
}
export default Calendar;

This basic React Calendar has a calendarRef that allows you to access the Bryntum Calendar instance. We don’t use this in this tutorial, but it can be useful.

Creating a config file

The Bryntum Calendar React component has its basic config passed in as props. Let’s define the config.

Create a config.ts file in the client/src folder and add the following lines of code to it:

import { BryntumCalendarProps } from '@bryntum/calendar-react';
const calendarProps: BryntumCalendarProps = {
    date             : new Date(2024, 11, 9),
    timeZone         : 'UTC',
    eventEditFeature : {
        items : {
            nameField : {
                required : true
            }
        }
    }
};
export { calendarProps };

Features, which can be configs and properties for the BryntumCalendar component, are suffixed with Feature. The EventEdit feature allows you to customize the event editor popup that contains fields for editing event data. Name is a required field.

Styling the component

Add the following stylesheet import to the client/src/config.ts file:

import '@bryntum/calendar/calendar.stockholm.css';

We use the Bryntum Stockholm theme to style the calendar, which is one of five available themes. You can also create custom themes or use multiple themes.

Add the following style to the client/src/App.css stylesheet to make the Bryntum Calendar take up the whole screen height:

#root {
  height: 100vh;
}

Rendering the Bryntum component

Replace the code in the client/src/App.tsx file with the following to render the Calendar component:

import Calendar from './components/calendar';
export default function App() {
    return (
        <Calendar />
    );
}

Now run the local dev servers using the following command:

npm run dev

Open http://localhost:5173. You should see an empty Bryntum Calendar:

You can add, edit, or delete events but the changes won’t persist after a page refresh.

Now let’s add tRPC to our Express server to create a type-safe API that connects our client-side Bryntum Calendar with our server-side SQLite database.

Adding tRPC to the Express server

We’ll create a tRPC router and add a query procedure for resources. We’ll then use the tRPC Express adaptor to convert the tRPC router to an Express middleware. We’ll also create a Zod Schema for the Bryntum calendar events and resources that we’ll use to validate tRPC procedure inputs and infer the TypeScript types from.

Installing the dependencies

Install the tRPC server and Zod:

npm install @trpc/server zod --workspace=server

The @trpc/server library allows one to create tRPC routers and connect them to a server. Zod isn’t a required dependency but it’s recommended for validating procedure inputs.

Creating a tRPC router and adding a resources query procedure

Create a router folder in the server/src folder and add an index.ts file to it.

Add the following lines of code to index.ts:

import { initTRPC } from '@trpc/server';
import { Resource } from '../models';
export const t = initTRPC.create();
export const appRouter = t.router({
    getResources : t.procedure.query(async() => {
        const resourcesData = await Resource.findAll();
        const resources = resourcesData.map((resource) => resource.dataValues);
        return resources;
    })
});
// export type definition of API
export type AppRouter = typeof appRouter;

This code initializes tRPC and creates a router, which is a collection of procedures. Procedures are API endpoints that are accessed via function calls.

The getResources query procedure then gets the resources data from the local SQLite database using the Sequelize findAll method on the Resource model.

Finally, it exports the router type, which we’ll use later on the client side.

Using the Express adapter to convert the tRPC router to an Express middleware

Let’s use the tRPC Express adapter to convert the tRPC router into an Express middleware.

Import the adapter and your router in the server/src/index.ts file:

import * as trpcExpress from '@trpc/server/adapters/express';
import { appRouter } from './router';

Now add your router as a middleware:

app.use(
    '/trpc',
    trpcExpress.createExpressMiddleware({
        router : appRouter,
        onError(opts) {
            const { error } = opts;
            console.error('Error:', error);
            if (error.code === 'INTERNAL_SERVER_ERROR') {
                // send to bug reporting
            }
        }
    })
);

All HTTP requests to the /trpc Express API endpoint are handled by the tRPC router. The onError method adds some basic error handling for all the errors that occur in a procedure and logs the errors to the console.

You can now access the getResources tRPC query endpoint by opening http://localhost:4000/trpc/getResources. You should see the following JSON resources data:

{
  "result": {
    "data": [
      {
        "id": 1,
        "name": "Alex",
        "eventColor": "#3183fe",
        "readOnly": false
      },
      {
        "id": 2,
        "name": "Bob",
        "eventColor": "#feac31",
        "readOnly": false
      },
      {
        "id": 3,
        "name": "Charlie",
        "eventColor": "#ff7043",
        "readOnly": false
      }
    ]
  }
}

The query procedure works, but there’s a TypeScript issue with the resources data. Open the server/src/router/index.ts file and hover your mouse over the resourcesData array in the getResources procedure to check its type. It has the following type:

Model<any, any>[]

However, it should have a resources array type. To type our Sequelize models, we’ll use Zod.

Creating Zod schemas and TypeScript types for the Bryntum Calendar events and resources

We’ll create Zod schemas to type our Sequelize models for better type safety and to validate tRPC procedure inputs.

In the server/src folder, create a zodSchema.ts file containing the following lines of code:

import { z } from 'zod';
export const ResourceSchema = z.object({
    id         : z.number(),
    name       : z.string(),
    eventColor : z.string().optional(),
    readOnly   : z.string().optional()
});

The ResourceSchema object schema represents most of the fields in the Bryntum Calendar Resource data model.

Now add a Zod object schema for the events data:

export const EventSchema = z.object({
    id             : z.number(),
    name           : z.string().optional(),
    readOnly       : z.boolean().optional(),
    resourceId     : z.coerce.number().optional(),
    timeZone       : z.string().optional(),
    draggable      : z.boolean().optional(),
    resizable      : z.string().optional(),
    allDay         : z.boolean().optional(),
    duration       : z.number().optional(),
    durationUnit   : z.string().optional(),
    startDate      : z.string().optional(),
    endDate        : z.string().optional(),
    exceptionDates : z.string().optional(),
    recurrenceRule : z.string().optional(),
    cls            : z.string().optional(),
    eventColor     : z.string().optional(),
    iconCls        : z.string().optional(),
    style          : z.string().optional()
});

The EventSchema object schema represents most of the fields in the Bryntum Calendar Event data model.

We can now use Zod’s type inference to extract the TypeScript types of the schema. Create a types.ts file in the server/src folder and add the following lines of code to it:

import { z } from 'zod';
import { EventSchema, ResourceSchema } from './zodSchema';
export type EventSchemaType = z.infer<typeof EventSchema>;
export type ResourceSchemaType = z.infer<typeof ResourceSchema>;

Let’s use these types to type the Sequelize models correctly.

In the server/src/models/Resource.ts file, add the following type imports and type definition:

import { Model, InferAttributes, InferCreationAttributes } from 'sequelize';
import { ResourceSchemaType } from '../types';

// The order of InferAttributes and InferCreationAttributes is important.
interface ResourceModel extends Model<InferAttributes<ResourceModel>, InferCreationAttributes<ResourceModel, { omit: 'id' }>>, ResourceSchemaType  {}

Sequelize models accept two generic types to define the attributes and creation attributes of a model. The InferAttributes and InferCreationAttributestypes extract attribute typings directly from the ResourceModel. The 'id' field is omitted from the creation attributes, as an id is not needed to create a new resource.

You can visit the Sequelize docs to learn more about using TypeScript with Sequelize.

Add the ResourceModel type to the Resource model definition:

- const Resource = sequelize.define(
+ const Resource = sequelize.define<ResourceModel>(

Now we’ll do the same for the Event model.

In the server/src/models/Event.ts file, add the following type imports and type definition:

import { Model, InferAttributes, InferCreationAttributes } from 'sequelize';
import { EventSchemaType } from '../types';
// The order of InferAttributes and InferCreationAttributes is important.
interface EventModel extends Model<InferAttributes<EventModel>, InferCreationAttributes<EventModel, { omit: 'id' }>>, EventSchemaType  {}

Add the EventModel type to the Event model definition:

- const Event = sequelize.define(
+ const Event = sequelize.define<EventModel>(

Open the server/src/router/index.ts file and hover your mouse over the resourcesData array in the getResources procedure. You’ll see that the data is now typed correctly:

ResourceModel[]

Creating query and mutation procedures for events

Add the following procedures to the tRPC router in server/src/router/index.ts:

getEvents : t.procedure.query(async() => {
    const eventsData = await Event.findAll();
    const events = eventsData.map((event) => event.dataValues);
    return events;
}),
createEvent : t.procedure.input(
    EventSchema.partial({ id : true })).mutation(async(opts) => {
    const newEvent = await Event.create(opts.input);
    return newEvent;
}),
updateEvent : t.procedure.input(
    EventSchema.partial()).mutation(async(opts) => {
    const { id, ...rest } = opts.input;
    await Event.update(rest, { where : { id } });
    return { success : true };
}),
deleteEvent : t.procedure.input(z.number()).mutation(async(opts) => {
    const id  = opts.input;
    await Event.destroy({ where : { id } });
    return { success : true };
})

Add the required imports:

import { z } from 'zod';
import { Event } from '../models';
import { EventSchema } from '../zodSchema';

The getEvents query procedure gets the resources data from the local SQLite database using the Sequelize findAll method on the Event model. The createEvent mutation procedure uses the EventSchema Zod schema to validate the input. The Sequelize create method creates a new event record in the SQLite database.

The updateEvent mutation procedure uses the Sequelize update method to update a database record, and the deleteEvent mutation procedure uses the Sequelize destroy method to delete a database record.

Setting up tRPC on the client: Setup React Query integration

tRPC has React Query integration that simplifies calling queries and mutations. We’ll import the AppRouter type into the client app to create the tRPC hooks that we’ll use to make queries and mutations.

Installing the dependencies

Install the required dependencies in the client app:

npm install @trpc/client @trpc/react-query @tanstack/react-query@4 --workspace=client

Importing the AppRouter from the server app

Create a utils.ts file in the client/src folder and add the following import:

import type { AppRouter } from '../../server/src/router';

The imported AppRouter type holds the shape of your entire API, from your server app to the client app. Only the type is imported, so that server-side code is not added to the client.

Creating tRPC React Query hooks

Add the following lines of code to the client/src/utils.ts file:

import { createTRPCReact } from '@trpc/react-query';
export const trpc = createTRPCReact<AppRouter>();

This creates a set of strongly-typed React Query hooks from the AppRouter type using createTRPCReact.

Creating tRPC and React Query clients and providers

Replace the code in the client/src/App.tsx file with the following lines of code:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from './utils';
import Calendar from './components/calendar';
import '@bryntum/calendar/calendar.stockholm.css';
const queryClient = new QueryClient();
const trpcClient = trpc.createClient({
    links : [
        httpBatchLink({
            url : 'http://localhost:4000/trpc'
        })
    ]
});
export default function App() {
    return (
        <trpc.Provider client={trpcClient} queryClient={queryClient}>
            <QueryClientProvider client={queryClient}>
                <Calendar />
            </QueryClientProvider>
        </trpc.Provider>
    );
}

React Query and tRPC both use React context. This code creates a React Query client and wraps your app in the React Query provider. It then does the same for tRPC.

The React Query QueryClient is used to manage and interact with a cache for queries and mutations. The QueryClientProvider component allows the <Calendar> component to use React Query methods.

The trpcClient links property is used to customize the flow of data between the tRPC Client and Server. The httpBatchLink is used to batch multiple requests into a single HTTP request for performance. The url is set to the /trpc Express server API endpoint, which is handled by the tRPC router.

Getting calendar data from the tRPC API into the Bryntum Calendar using the tRPC React Query useQuery hook

In the client/src/components/calendar.tsx file, add the following imports:

import { useMemo } from 'react';
import { trpc } from '../utils';

Add the following tRPC React Query useQuery hooks to the Calendar function to fetch the resources and events data:

const
    resourcesQuery = trpc.getResources.useQuery(undefined, {
        refetchOnWindowFocus : false
    }),
    eventsQuery = trpc.getEvents.useQuery(undefined, {
        refetchOnWindowFocus : false
    });
const
    events = useMemo(() => eventsQuery?.data || [], [eventsQuery]),
    resources = useMemo(() => resourcesQuery?.data || [], [resourcesQuery]);

The useMemo hook ensures the <BryntumCalendar> component receives the latest updated data. Without it, the calendar data won’t update when the useQuery data fetches are completed.

Pass the data as props in the rendered <BryntumCalendar> component:

events={events}
resources={resources}

This binds the data to the component.

Open http://localhost:5173. You should see the events and resources in the Bryntum Calendar:

Syncing data changes with the Bryntum Calendar onDataChange event

We’ll use the Bryntum Calendar onDataChange event to call the tRPC procedure mutations when the calendar data changes.

Creating a syncData function to sync data changes

Add the onDataChange property to the BryntumCalendar component in the client/src/components/calendar.tsx file:

  onDataChange={syncData}

When a data change occurs in the Bryntum Calendar, the dataChange event is fired and the syncData function is called.

Let’s define the skeleton of this function within the Calendar function:

    const syncData = ({ store, action, record, records, changes }: SyncData) => {
        const storeId = store.id;
        if (storeId === 'events') {
            if (action === 'remove') {
            }
            if (action === 'update') {
            }
        }
    };

The dataChange event provides information about the store, action, record, records, and changes. The store is used to determine which data store has been changed (in this case, only events are updated). The action determines which type of data change has occurred – "add", "remove", or "update".

We won’t use the "add" event when an event is created, we’ll create the event when it’s first updated. This is because when an event is created in the Bryntum Calendar, an "add" event occurs and then the event editor popup is opened. An "update" event occurs when the new calendar event is saved. We’ll create the event when this "update" occurs.

Now add the following data type imports to the top of the client/src/components/calendar.tsx file:

import { EventModel, ResourceModel } from '@bryntum/calendar';

Below the imports, add the SyncData type used for the syncData function:

type SyncData  = {
    action: 'dataset' | 'add' | 'remove' | 'update';
    record: ResourceModel | EventModel;
    records: {
        data: ResourceModel | EventModel ;
        meta: {
          modified: Partial<ResourceModel> | Partial<EventModel>;
        };
      }[];
    changes: Partial<ResourceModel> | Partial<EventModel>;
    store: {
      id: 'resources' | 'events';
    };
  };

Now let’s use the tRPC React Query useMutation hook to call our mutation procedures so that the client-side changes in the Bryntum Calendar remain in sync with the server app’s SQLite database.

Deleting events using useMutation

Add the following lines of code to the Calendar function:

const utils = trpc.useUtils();
const eventDeleteMutation = trpc.deleteEvent.useMutation({
    onSuccess() {
      utils.getEvents.invalidate();
    }
});

This uses the tRPC React Query integration to create an eventDeleteMutationwith useMutation. The mutation can call the deleteEvent mutation procedure to delete events from the database. After the delete mutation, the events data is invalidated and refetched.

You can visit the tRPC docs to learn more about query invalidation.

If you need better performance, you can update the data cache on the client instead of refetching data when the mutation is successful.

You can use the Tanstack Query queryClient to interact with a cache.

Add the following deleteEvent function to the Calendar function:

function deleteEvent(id: number) {
    eventDeleteMutation.mutate(id);
}

You can call this function to delete an event.

In the syncData function, where storeId === 'events', add the following lines of code to the if (action === 'remove') statement:

if (`${records[0]?.data?.id}`.startsWith('_generated')) return;
records.forEach((record) => {
    deleteEvent(Number(record.data.id));
});

This loops through the deleted records objects and calls the deleteEvent function for each object. Resources with an id that starts with '_generated' won’t be deleted, as they have not been created on the server yet. New records in the Bryntum Calendar client-side data store are assigned a UUID id prefixed with _generated. This is a temporary id that should be replaced by an id created on the server.

Adding and updating events using useMutation

Add the following tRPC React Query useMutation hooks:

    const eventCreateMutation = trpc.createEvent.useMutation({
        onSuccess() {
            utils.getEvents.invalidate();
        }
    });

    const eventUpdateMutation = trpc.updateEvent.useMutation({
        onSuccess() {
            utils.getEvents.invalidate();
        }
    });

Add the following createEvent function to the Calendar function:

async function createEvent(record: EventModel) {
        const { id, resourceId, startDate, endDate, timeZone, recurrenceRule, resizable, exceptionDates, cls, eventColor, eventStyle, ...rest } = record;
        eventCreateMutation.mutate({
                resourceId     : Number(resourceId),
                timeZone       : timeZone ? `${timeZone}` : undefined,
                resizable      : `${resizable}`,
                exceptionDates : `${exceptionDates}`,
                startDate      : `${startDate}`,
                endDate        : `${endDate}`, ...rest,
                recurrenceRule : recurrenceRule ? `${recurrenceRule}` : undefined
        });
}

Add the following updateEvent function to the Calendar function:

async function updateEvent(id: number, changes: Partial<EventModel>) {
    const newValues = Object.fromEntries(
        Object.entries(changes).map(([key, obj]) => [key, obj.value])
    );
    if (newValues.duration === null) return;
    if (newValues.timeZone) {
        newValues.timeZone = `${newValues.timeZone}`;
    }
    else {
        newValues.timeZone = undefined;
    }
    eventUpdateMutation.mutate(
        { id, ...newValues }
    );
}

In the syncData function, where storeId === ‘events’, add the following lines of code to the if (action === 'update') statement:

if (`${record.id}`.startsWith('_generated')) return;
updateEvent(Number(record.id), changes as Partial);

We know that the id for each newly created event is prefixed with '_generated', whereas each existing event is assigned an unprefixed id by SQLite. This code updates events that have already been added to the SQLite database, it calls the mutateUpdate mutation, passing in the modified variables.

To create events, we’ll use the afterEventSave method, which occurs after an event is successfully saved. Add the following afterEventSave function below the syncData function:

const afterEventSave = (event: EventModel) => {
    createEvent(event.eventRecord.data as EventModel);
};

We create a new event using our createEvent function.

Add the onAfterEventSave property to the BryntumCalendar component and pass in the afterEventSave function as a value:

onAfterEventSave={afterEventSave}

Run the local dev server. Data changes in your Bryntum Calendar are now saved to the local SQLite database:

Next steps

This tutorial provides a starting point for using Bryntum’s React Calendar with tRPC in a TypeScript monorepo.

Our Bryntum Calendar demo page will give you an idea of the additional features you can add to your calendar.

You can also use our Calendar using Next.js. When using Next.js, you’ll need to load the calendar components dynamically.

Arsalan Khattak

Bryntum Calendar