How to use Liveblocks Storage with React

In this guide, we’ll be learning how to use Liveblocks Storage with React using the hooks from the @liveblocks/react package.

This guide uses TypeScript. Liveblocks can definitely be used without TypeScript. We believe typings are helpful to make collaborative apps more robust, but if you’d prefer to skip the TypeScript syntax, feel free to write your code in JavaScript

Sync and persist the state across client

Some collaborative features require a single shared state between all users—an example of this would be a collaborative design tool, with each shape having its own state, or a form with shared inputs. In Liveblocks, this is where storage comes in. Room storage automatically updates for every user on changes, and unlike presence, persists after users disconnect.

Storage types

Our storage uses special data structures (inspired by CRDTs) to resolve all conflicts, meaning that state is always accurate. There are multiple storage types available:

  • LiveObject - Similar to a JavaScript object.
  • LiveList - An array-like ordered collection of items.
  • LiveMap - Similar to a JavaScript Map.

Defining initial storage

To use storage, first define a type named Storage in liveblocks.config.ts, like we did for Presence. In this example we’ll define a LiveObject called scientist, containing first and last name properties.

liveblocks.config.ts
import { createClient, LiveObject } from "@liveblocks/client";// ...
type Storage = { scientist: LiveObject<{ firstName: string; lastName: string }>;};
export const { RoomProvider, /* exported hooks */} = createRoomContext<Presence, Storage>(client);

Then, define the initial structure within RoomProvider.

index.ts
import { LiveObject } from "@liveblocks/client";import { RoomProvider } from "./liveblocks.config";
/* App */
function Index() { return ( <RoomProvider id="my-room-id" initialPresence={/* ... */} initialStorage={{ scientist: new LiveObject({ firstName: "Marie", lastName: "Curie", }), }} > <App /> </RoomProvider> );}

Using storage

Once the default structure is defined, we can then make use of our storage. The useStorage hook allows us to access an immutable version of our storage using a selector function.

import { useStorage } from "./liveblocks.config";
function App() { const scientist = useStorage((root) => root.scientist);
if (scientist == null) { return <div>Loading...</div>; }
return ( <> <input value={scientist.firstName} /> <input value={scientist.lastName} /> </> );}

The two input values will now automatically update in a real-time as firstName and lastName are modified by other users.

useStorage returns null during the initial loading because the storage is loaded from the server. It can quickly become cumbersome to handle null whenever we use useStorage, but we have some good new for you; @liveblocks/react contains a Suspense version of all of our hooks.

Updating storage

The best way to update storage is through mutations. The useMutation hook allows you to create reusable callback functions that modify Liveblocks state. For example, let’s create a mutation that can modify the scientist’s name.

Inside this mutation we’re accessing the storage root, a LiveObject like scientist, and retrieving a mutable copy of scientist with LiveObject.get. From there, we can set the updated name using LiveObject.set.

// Define mutationconst updateName = useMutation(({ storage }, nameType, newName) => {  const mutableScientist = storage.get("scientist");  mutableScientist.set(nameType, newName);}, []);

We can then call this mutation, and pass nameType and newName arguments.

updateName("firstName", "Albert");

If we take a look at this in the context of a component, we can see how to combine useStorage to display the names, and useMutation to modify them. Note that useMutation takes a dependency array, and works similarly to useCallback.

index.ts
import { useStorage, useMutation } from "./liveblocks.config";
function YourComponent() { const scientist = useStorage((root) => root.scientist);
const updateName = useMutation(({ storage }, nameType, newName) => { const mutableScientist = storage.get("scientist"); mutableScientist.set(nameType, newName); }, []);
return ( <> <input value={scientist.firstName} onChange={(e) => updateName("firstName", e.target.value)} /> <input value={scientist.lastName} onChange={(e) => updateName("lastName", e.target.value)} /> </> );}

All changes made within useMutation are automatically batched and sent to the Liveblocks together. useMutation can also be used to retrieve and modify presence too, giving you access to multiple parameters, not just storage.

useMutation({ storage, self, others, setMyPresence });

Find more information in the Mutations section of our documentation.

Nested data structures

With Liveblocks storage, it’s possible to nest data structures inside each other, for example scientist could hold a LiveList containing a list of pets.

initialStorage={{  scientist: new LiveObject({    pets: new LiveList(["🐶", "🐱", "🐷"]),    firstName: "Marie",    lastName: "Curie",  })}}

Because the useStorage selector converts your data structure into a normal immutable JavaScript structure (made from objects, arrays, maps), pets can be accessed directly with useStorage.

// ["🐶", "🐱", "🐷"]const pets = useStorage((root) => root.scientist.pets);

You can even reach into a LiveObject or LiveList and extract a property.

// "Marie"const firstName = useStorage((root) => root.scientist.firstName);
// "🐶"const firstPet = useStorage((root) => root.scientist.pets[0]);

Improving storage performance

useStorage is highly efficient and only triggers a rerender when the value returned from the selector changes. For example, the following selectors will only trigger rerenders when their respective values change, and are unaffected by any other storage updates.

// ✅ Rerenders only when root.scientist.firstName changesconst firstName = useStorage((root) => root.scientist.firstName);
// ✅ Rerenders only when root.scientist changesconst scientist = useStorage((root) => root.scientist);

However, selector functions must return a stable result to be efficient—if a new object is created within the selector function, it will rerender on every storage change.

// ❌ Rerenders on every change because `map` returns a new array every timeconst pets = useStorage((root) => root.scientist.pets.map((pet) => pet + pet));

To account for this, we can pass a shallow equality check function, provided by @liveblocks/react:

import { shallow } from "@liveblocks/react";
// ✅ Rerenders only when root.scientist.pets shallowly changesconst pets = useStorage( (root) => root.scientist.pets.map((pet) => pet + pet), shallow);

Find more information in the How selectors work section of our documentation.

Using Suspense

If you’d like to use Suspense in your application, make sure to re-export our hooks from liveblocks.config.ts like so.

liveblocks.config.ts
// ...
export const { suspense: { RoomProvider, useStorage, /* exported hooks */ },} = createRoomContext<Presence, Storage>(client);

And then put a Suspense component right below the RoomProvider. This version of useStorage never returns null, the loading fallback will be handled by Suspense fallback.

index.ts
import { LiveObject } from "@liveblocks/client";import { Suspense } from "react";import { RoomProvider, useSelector } from "./liveblocks.config";
function App() { const scientist = useStorage((root) => root.scientist);
return ( <> <input value={scientist.firstName} /> <input value={scientist.lastName} /> </> );}
function Index() { return ( <RoomProvider id="my-room-id" initialPresence={/* ... */} initialStorage={{ scientist: new LiveObject({ firstName: "Marie", lastName: "Curie", }), }} > <Suspense fallback={<div>Loading</div>}> <App /> </Suspense> </RoomProvider> );}

If you’re using a framework that supports Server Side Rendering like Next.js, you cannot use Suspense directly like this. Liveblocks does not load the storage on the server by default, so the components using useStorage will never be able to render. To keep the benefits from Suspense, you should use ClientSideSuspense from @liveblocks/react instead of the normal Suspense from React like this:

index.ts
import { ClientSideSuspense } from "@liveblocks/react";
// ...
function Index() { return ( <RoomProvider id="my-room-id" initialPresence={/* ... */} initialStorage={/* ... */} > <ClientSideSuspense fallback={<div>Loading...</div>}> {() => <App />} </ClientSideSuspense> </RoomProvider> );}