Get started - Get started with React

In this guide, we’ll be learning how to add collaboration to React using the Liveblocks custom hooks. The hooks are part of @liveblocks/react, a package enabling multiplayer experiences in a matter of minutes.

If you’re using a state-management library such as Redux or Zustand, we recommend reading one of our dedicated guides:

You can also follow our step-by-step tutorial to learn how to use Liveblocks.

Install Liveblocks into your project

Install Liveblocks packages

Run the following command to install the Liveblocks packages:

npm install @liveblocks/client @liveblocks/react

@liveblocks/client lets you interact with Liveblocks servers.
@liveblocks/react contains React providers and hooks to make it easier to consume @liveblocks/client.

Connect to Liveblocks servers

In order to use Liveblocks, we’ll need to sign up and get an API key. Create an account, then navigate to the dashboard to find your public key (it starts with pk_).

Let’s now add a new file src/liveblocks.config.js in our application to create a Liveblocks client using the public key as shown below.

import { createClient } from "@liveblocks/client";
const client = createClient({ publicApiKey: "pk_prod_xxxxxxxxxxxxxxxxxxxxxxxx",});

Connect to a Liveblocks room

Liveblocks uses the concept of rooms, separate virtual spaces where people can collaborate. To create a multiplayer experience, multiple users must be connected to the same room.

Instead of using the client directly, we’re going to use createRoomContext from @liveblocks/react to create a RoomProvider and hooks to make it easy to consume from our components.

import { createClient } from "@liveblocks/client";import { createRoomContext } from "@liveblocks/react";
const client = createClient({ publicApiKey: "pk_prod_xxxxxxxxxxxxxxxxxxxxxxxx",});
export const { RoomProvider } = createRoomContext(client);

You might be wondering why we’re creating our Providers and Hooks with createRoomContext instead of importing them directly from @liveblocks/client. This allows TypeScript users to define their Liveblocks types once in one unique location—allowing them to get a great autocompletion experience when using those hooks elsewhere.

We can now import the RoomProvider directly from our src/liveblocks.config.js file. The RoomProvider takes a room id as a property, this being the unique reference for the room. For this tutorial we’ll use "my-room-id" as our id. When the RoomProvider renders, the current user enters the room "my-room-id" and leaves it when it unmounts.

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

You may also notice that we’re using ClientSideSuspense from @liveblocks/react in this example. This is a component that adds React’s Suspense feature by wrapping our app in a Suspense boundary. Doing so enables the suspense version of our hooks—an option we recommend using for simpler component code.

The component passed to the fallback property will be displayed until Liveblocks is connected and ready to go. ClientSideSuspense can also be moved further down the tree, so that specific parts of your app load exactly when ready.

ClientSideSuspense

If you’re familiar with Suspense, you may be wondering why we’re using ClientSideSuspense from @liveblocks/react instead of the normal Suspense component from React itself. This is only done so this tutorial will also work in projects that use server-side rendering (e.g. in a Next.js project). You may not strictly need it. Details can be found here if you’re interested.

Get other users in the room

Now that the provider is set up, we can start using the Liveblocks hooks. The first we’ll add is useOthers, a hook that provides us information about which other users are connected to the room.

We can re-export this from liveblocks.config.js, and because we’re using suspense in this example, we’ll export all our hooks from the suspense property.

import { createClient } from "@liveblocks/client";import { createRoomContext } from "@liveblocks/react";
const client = createClient({ publicApiKey: "pk_prod_xxxxxxxxxxxxxxxxxxxxxxxx",});
export const { suspense: { RoomProvider, useOthers, // 👈 },} = createRoomContext(client);

To show how many other users are in the room, import useOthers into a component and use it as below.

import { useOthers } from "./liveblocks.config";
function App() { const others = useOthers();
return <div>There are {others.count} other users with you in the room.</div>;}

Great! We’re connected, and we already have information about the other users currently online.

Define initial presence

Most collaborative features rely on each user having their own temporary state, which is then shared with others. For example, in an app using multiplayer cursors, the location of each user’s cursor will be their state. In Liveblocks, we call this presence.

We can use presence to hold any object that we wish to share with others. An example would be the pixel coordinates of a user’s cursor:

cursor: { x: 256, y: 367 }

To start using presence, first we must define an initialPresence value on our RoomProvider. We’ll set the initial cursor to null to represent a user whose cursor is currently off-screen.

import App from "./App";import { ClientSideSuspense } from "@liveblocks/react";import { RoomProvider } from "./liveblocks.config.js";
function Index() { return ( <RoomProvider id="my-room-id" initialPresence={{ cursor: null }}> <ClientSideSuspense fallback={<div>Loading...</div>}> {() => <App />} </ClientSideSuspense> </RoomProvider> );}

Update user presence

We can add the useUpdateMyPresence hook to share this information in real-time, and in this case, update the current user cursor position when onPointerMove is called.

First, re-export useUpdateMyPresence like we did with useOthers.

// ...
export const { suspense: { RoomProvider, useOthers, useUpdateMyPresence, // 👈 },} = createRoomContext(client);

To keep this guide concise, we’ll assume that you now understand how to re-export your hooks for every new hook.

Next, import updateMyPresence and call it with the updated cursor coordinates whenever a pointer move event is detected.

import { useUpdateMyPresence } from "./liveblocks.config";
function App() { const updateMyPresence = useUpdateMyPresence();
return ( <div style={{ width: "100vw", height: "100vh" }} onPointerMove={(e) => updateMyPresence({ cursor: { x: e.clientX, y: e.clientY } }) } onPointerLeave={() => updateMyPresence({ cursor: null })} /> );}

We’re setting cursor to null when the user’s pointer leaves the element.

Get other users’ presence

To retrieve each user’s presence, and cursor locations, we can once again add useOthers. This time we’ll use a selector function to map through each user’s presence, and grab their cursor property. If a cursor is set to null, a user is off-screen, so we’ll skip rendering it.

import { useOthers } from "./liveblocks.config";
function App() { // List of other users const others = useOthers();
// If a cursor is on screen (not null), render return ( <> {others.map(({ connectionId, presence }) => presence.cursor ? ( <Cursor key={connectionId} x={presence.cursor.x} y={presence.cursor.y} /> ) : null )} </> );}
// Basic cursor componentfunction Cursor({ x, y }) { return ( <img style={{ position: "absolute", transform: `translate(${x}px, ${y}px)`, }} src="cursor-image.svg" /> );}

Presence isn’t only for multiplayer cursors, and can be helpful for a number of other use cases such as live avatar stacks and real-time form presence.

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 we need to define the initial structure within RoomProvider. In this example we’ll define a LiveObject called scientist, containing first and last name properties.

import App from "./App";import { ClientSideSuspense } from "@liveblocks/react";import { LiveObject } from "@liveblocks/client";import { RoomProvider } from "./liveblocks.config.js";
function Index() { return ( <RoomProvider id="my-room-id" initialPresence={/* ... */} initialStorage={{ scientist: new LiveObject({ firstName: "Marie", lastName: "Curie", }), }} > <ClientSideSuspense fallback={<div>Loading...</div>}> {() => <App />} </ClientSideSuspense> </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 YourComponent() { const scientist = useStorage((root) => root.scientist);
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.

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.

import { useStorage } 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.

Multiplayer undo/redo

Implementing undo/redo in a multiplayer environment is notoriously complex, but Liveblocks provides functions to handle it for you. useUndo and useRedo return functions that allow you to undo and redo the last changes made to your app.

import { useUndo, useRedo } from "./liveblocks.config";
function App() { const undo = useUndo(); const redo = useRedo();
return ( <> <button onClick={() => undo()}>Undo</button> <button onClick={() => redo()}>Redo</button> </> );}

An example of this in use would be a button that updates the current firstName of a scientist. Every time a Liveblocks storage change is detected, in this case .set being called, it’s stored. Pressing the undo button will change the name back to its previous value.

import { useState } from "react";import { useMutation, useUndo } from "./liveblocks.config";
function YourComponent() { const [text, setText] = useState(""); const undo = useUndo();
const updateName = useMutation(({ storage }, newName) => { storage.get("scientist").set("firstName", newName); });
return ( <> <input type="text" value={text} onChange={(e) => setText(e.target.value)} /> <button onClick={() => updateName(text)}>Update Name</button> <button onClick={() => undo()}></button> </> );}

Multiplayer undo/redo is much more complex that it sounds—if you’re interested in the technical details, you can find more information in our interactive article: How to build undo/redo in a multiplayer environment.

Pause and resume history

Sometimes it can be helpful to pause undo/redo history, so that multiple updates are reverted with a single call.

For example, let’s consider a design tool; when a user drags a rectangle, the intermediate rectangle positions should not be part of the undo/redo history, otherwise pressing undo may only move the rectangle one pixel backwards. However, these small pixel updates should still be transmitted to others, so that the transition is smooth.

useHistory is a hook that allows us to pause and resume history states as we please.

import { useHistory } from "./liveblocks.config";
function App() { const { resume, pause } = useHistory();
return <Rectangle onDragStart={() => pause()} onDragEnd={() => resume()} />;}

Presence history

By default, undo/redo only impacts the room storage—there’s generally no need to use it with presence, for example there’s no reason to undo the position of a user’s cursor. However, occasionally it can be useful.

If we explore the design tool scenario, the currently selected rectangle may be stored in a user’s presence. If undo is pressed, and the rectangle is moved back, it would make sense to remove the user’s selection on that rectangle.

To enable this, we can use the addToHistory option when updating the user’s presence.

import { useUpdateMyPresence } from "./liveblocks.config";
function App() { const updateMyPresence = useUpdateMyPresence();
return ( <Rectangle onClick={(rectangleId) => updateMyPresence({ selected: rectangleId }, { addToHistory: true }) } /> );}

This also works in mutations with setMyPresence.

import { useMutation } from "./liveblocks.config";
const updateSelected = useMutation(({ setMyPresence }, rectangleId) => { setMyPresence({ selected: rectangleId }, { addToHistory: true });});

Next steps

Congratulations! You’ve learned the basic building blocks behind real-time Liveblocks applications. What’s next?

© 2023 Liveblocks Inc.Edit this page