Product updates

What’s new in v0.18

This release brings you powerful React hooks that return immutable data, React selector APIs, React Suspense support, improved core performance, and specialized React hooks for optimized use cases.

Picture of Vincent Driessen
Vincent Driessen
@nvie
What’s new in v0.18

At Liveblocks, we strive to craft ergonomic and easy-to-use APIs that allow developers to build performant multiplayer experiences. With 0.18, we’re making some important changes in that direction:

The new APIs we’re introducing here solve many subtle and not-so-subtle pain points. We heard your feedback, and think we have shipped something awesome that you’ll love.

New React hooks that return immutable data

With 0.18, we’re introducing two main new hooks: useStorage and useMutation, which should make reading and writing to Storage much more natural in a React world. The biggest conceptual shift is that the useStorage hook lets you consume data using normal JavaScript data structures (objects, arrays, maps) that are immutable.

This means you’ll no longer have to deal with LiveObject, LiveList, or LiveMap when reading data—only when mutating!

While the useObject, useList, and useMap hooks will still work in 0.18, we recommend migrating to useStorage and useMutation. We’ve written a step-by-step guide to help you migrate. If you’re stuck, please reach out to us on GitHub, and we’ll lend you a hand!

useStorage

This improves the developer experience significantly, enabling you to turn code that looks like this…

// ❌ Beforefunction Component() {  const scientist = useObject("scientist");  if (scientist == null) {    return null;  }
const pets = scientist.get("pets").toArray(); // ["🐶", "🐈"]}

into code that looks like this…

// ✅ Nowfunction Component() {  const pets = useStorage((root) => root.scientist.pets);  // ["🐶", "🐈"]}

Much better, right?

But that’s not all. Rerendering your components when nested data—like our scientist’s pets list—changes was a true head breaker before. This required extra helper components, manual subscriptions, and manual conversion to “normal” JavaScript arrays.

// ❌ Beforefunction Component() {  const scientist = useObject("scientist");  if (scientist == null) {    return null;  }
return <Pets livePets={scientist.get("pets")} />;}
function Pets({ livePets }) { const room = useRoom(); const [pets, setPets] = useState(livePets.toArray());
useEffect(() => { return room.subscribe(livePets, () => { setPets(livePets.toArray()); }); }, [room, livePets]);}

Now, rerendering your component when data changes is automatic, even for deeply nested data.

// ✅ Nowfunction Component() {  const pets = useStorage((root) => root.scientist.pets);  // ["🐶", "🐈"]}

useStorage is also helpful when you need to derive a computed value from different objects. Previously, it took some manual setup to ensure the component would automatically rerender when either of those values changed.

// ❌ Beforefunction Component() {  const objA = useObject("a");  const objB = useObject("b");
const room = useRoom(); const [sum, setSum] = useState(); // ^^^ We’re trying to compute the result of a.x + b.x in here
useEffect(() => { function onChange() { setSum(objA.get("x") + objB.get("x")); }
const unsubA = room.subscribe(objA, onChange); const unsubB = room.subscribe(objB, onChange);
return () => { unsubA(); unsubB(); }; }, [room, objA, objB]);}

The code can now be simplified as follow to rerender automatically any time a.x or b.x changes, but not more often.

// ✅ Nowfunction Component() {  const sum = useStorage((root) => root.a.x + root.b.x);}

Now, this is fully automatic. Or should we say, automagic? useStorage feels right at home in React now: say bye to those redundant useEffect with room.subscribe hooks!

useMutation

Now that reading from the Liveblocks storage state is all read-only and no longer returns mutable data structures, how do we mutate things? The answer is the new useMutation hook.

It’s almost like a normal React useCallback, but it provides you access to the mutable Storage root through the first callback argument. For instance, here is what a fill action could look like in a design tool. Simply pass the id and the color of the shape you want to fill.

const fill = useMutation(  // Note the second and third arguments  ({ storage, setMyPresence }, shapeId: string, color: string) => {    storage.get("shapes").get(shapeId).set("fill", color);    setMyPresence({ lastUsedColor: color });  },  []);
return <button onClick={() => fill("shape1", "red")} />;

Notice how we’re able to also set the last used color for the current user. Handy, right? Especially when you start using this for more advanced scenarios like deleting all the shapes that are currently selected.

const deleteSelectedShapes = useMutation(  // You can use the current "self" or "others" state in the mutation  ({ storage, self, others, setMyPresence }) => {    // Delete the selected shapes    const shapes = storage.get("shapes");    for (const shapeId of self.presence.selectedShapeIds) {      shapes.delete(shapeId);    }
// Clear the current selection setMyPresence({ selectedShapeIds: [] }); }, []);
return <button onClick={deleteSelectedShapes} />;

With useMutation, you can now write custom mutation logic for your application where both presence and storage work seamlessly together—enabling you to build world-class multiplayer experiences, no matter how complex the experience you’re trying to build is.

What’s also nice about it is that it allows you the choice of how to organize your mutation code. You can colocate it directly in your components, or centralize them as common actions, making it easier for larger teams to interact with the state at scale.

To read more about how mutations work exactly, please refer to the useMutation API reference.

React selector APIs

As you may have seen in the section above, useStorage takes a selector function. With 0.18, we’re also bringing these selectors to the existing useSelf and useOthers hooks. The selector APIs receive immutable data, return arbitrary values, and automatically subscribe to updates.

The received input to all selector functions is a read-only and immutable top-level context value that differs for each hook:

  • useStorage((root) => ...) receives the Storage root
  • useSelf((me) => ...) receives the current user
  • useOthers((others) => ...) receives a list of other users in the room
  • useOthersMapped((other) => ...) receives each individual other user in the room
  • useOther(connectionId, (other) => ...) receives a specific user in the room
const animals = useStorage((root) => root.animals);// ["🦁", "🦊", "🐵"]
const ada = useStorage((root) => root.mathematician);// { firstName: "Ada", lastName: "Lovelace" }
const fullname = useStorage( (root) => `${root.mathematician.firstName} ${root.mathematician.lastName}`);// "Ada Lovelace"
const fruits = useStorage((root) => [...root.fruitsByName.values()], shallow);// ["🍎", "🍌", "🍒"]

For full details, you can read more in the how selectors work section of our documentation.

React Suspense support

The benefit of using Suspense with Liveblocks is that the hooks will no longer return null when Liveblocks is still loading. Instead, you can let your Suspense boundary handle the still-loading case centrally by showing the fallback state.

This improves the developer experience significantly, enabling you to turn code that looks like this…

function MyComponent() {  const cursor = useSelf((me) => me.presence.cursor);
if (cursor === null) { return null; }
const { x, y } = cursor; return <Cursor x={x} y={y} />;}

into code that looks like this…

function MyComponent() {  const { x, y } = useSelf((me) => me.presence.cursor);  return <Cursor x={x} y={y} />;}

Much better, right?

If you want to learn more about using Suspense with React, please make sure to read the guide in the documentation.

Improved core performance

For this release, we’ve had to refactor several parts of our core. Not only did this make the @liveblocks/react package size go down from 12.6kB to 8.2kB minified, but it also made the core faster, more scalable, and future-proof.

We’ve also made several changes to the current functionalities in Liveblocks core. Setting the initial presence upon entering a room is now required, for example, either by calling client.enter() or using RoomProvider. This means that when a user enters the room, their presence is instantly recognized. In the past, there would be a brief moment when a user’s presence data wasn’t known yet and it would be unknown, causing a UI flash in some situations. But this is no longer the case — either the user is fully known, or they will not show up in the room until then.

Specialized React hooks for optimized use cases

Lastly, in addition to the useStorage and useMutation hooks above, we added a few more specialized pragmatic hooks to assist with building for two common use cases where rendering performance matters:

  • useOthersMapped to select subsets of others
  • useOthersConnectionIds and useOther to build highly optimized UIs

These can be particularly useful for rendering live cursors in highly interactive applications where multiple people are simultaneously editing the same document.

If you’re interested in learning more, please read the API reference for useOthersMapped, useOthersConnectionIds, and useOther.

Contributors

Huge thanks to everyone who contributed! Keep checking out the changelog for the full release notes – and see you next time!