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 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:
- Add a Bryntum Calendar component to a React Vite client app.
- Add tRPC to an Express server app and use tRPC procedures to create API endpoints to query a local SQLite database using Sequelize ORM.
- Create a tRPC React Query client to use the tRPC procedures in the client app.
- Use tRPC queries to load data into the Bryntum Calendar.
- Use tRPC mutations to sync data changes in the Bryntum Calendar to the SQLite database.
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:
- Sequelize instantiation code (
server/src/config/database.ts
): Code to create a Sequelize instance that uses a local SQLite database, stored as adatabase.sqlite3
file in theserver
folder. - Example data (
server/src/initialData
): The example JSON data for events and resources that is used to populate the database. - Sequelize data models (
server/src/models
): Sequelize models used to define the structure of the Bryntum Calendar database tables. - Database seeding script (
server/src/addExampleData.ts
): A Node.js script that uses Sequelize to create a local SQLite database and populate it with the 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.
- If you’re using the trial version, use the following command:
npm install @bryntum/calendar@npm:@bryntum/calendar-trial @bryntum/calendar-react --workspace=client
- If you’re using the licensed version, use the following command:
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 InferCreationAttributes
types 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 eventDeleteMutation
with 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.