Using Room Service in React

While you can now use Room Service in any framework, Room Service was originally built as a love-letter to React and its ecosystem. The React client can do everything our other clients can do and we go to great lengths to make sure the React client is optimized for ambitious use cases:

  • Hooks!
  • Recoil-like cross-app observation without rerendering the whole tree. (fast!)
  • Optimistic updates with conflict resolution. (fast and less bugs!)
  • ~10kb minimized

Installing

You can get the React client with:

yarn add @roomservice/react

# Or...

npm install --save @roomservice/react

If you're using the React client, you do not need to also have the browser client installed.

Setting up

At the top of your application, add a <RoomServiceProvider /> component.

import { RoomServiceProvider } from "@roomservice/react";

async function AuthCheck({ room }) {
  // We'll get to this in a moment.
}

function MyApp({ Component, pageProps }) {
  return (
    <RoomServiceProvider
      clientParameters={{
        auth: AuthCheck,
      }}
    >
      <Component {...pageProps} />
    </RoomServiceProvider>
  );
}

export default MyApp;

In order to check if a user is allowed to access something in Room Service, the client asks you to pass in a function that will be called when the user first connects.

This function should make a request to your "Auth Webhook" and return the result. You can learn more about what that looks like in the How to Add Authentication guide. But for the moment, let's add something simple:

async function AuthCheck({ room }) {
  const response = await fetch("http://localhost:8080/api/roomservice", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    credentials: "include",
    body: JSON.stringify({
      room: params.room,
    }),
  });

  if (response.status === 401) {
    throw new Error("Unauthorized!");
  }

  const body = await response.json();
  return body;
}

Using a map

Maps are the simplest data structure in Room Service. You can think of Maps as a distributed hashmap; if you make a change to one, it will be applied locally first and sent over the network separately. That means maps are fast. Really fast.

You can use maps with a hook. Let's make one that represents a cool coffee shop down the street.

// "cafe" is the pure javascript object
// "map" is an instance of a class, with methods you can use to manipulate the
// map. It's `undefined` until the initial load of the room is complete
const [cafe, map] = useMap("myroom", "java-beach-cafe");

Let's allow a user change the title and description of the coffee shop with an input.

function CoffeeShopForm() {
  const [cafe, map] = useMap("myroom", "java-beach-cafe");

  return (
    <div>
      <label>
        Title
        <input
          value={cafe.title}
          onChange={(e) => map?.set("title", e.target.value)}
        />
      </label>

      <label>
        Description
        <input
          value={cafe.description}
          onChange={(e) => map?.set("description", e.target.value)}
        />
      </label>
    </div>
  );
}

With this code alone, every user who is in the "myroom" room will automatically see changes from other users in real-time. The cafe object will update automatically when it receives changes from other users, and when map.set makes a change.

Objects as values

You can put anything that can convert to JSON in as the value of the map, including javascript objects. For example:

const [scene, map] = useMap("myroom", "scene");

map?.set("frame", {
  id: "frame_123",
  background: "red",
  tags: ["cool", "tags"],
});

The resulting scene will then look like this:

{
  frame: {
    id: "frame_123",
    background: "red",
    tags: ["cool", "tags"]
  }
}

In this case, the entire object will be overwritten when you change "frame". If you want property level updates, you'll need to use different keys.

Embedding things inside of maps

If you want to embed maps inside of maps, just store a string key for another map. For example:

const [outer, outerMap] = useMap("myroom", "outer");
const [inner, innerMap] = useMap("myroom", outer.innerID);

outerMap?.set("inner", "inner-id");

However, we recommend sticking to flatter hierarchies when possible. Flatter hierarchies tend to be eaiser to reason about in a multi-user enviornment that supports both property-level updates and overwriting. Plus, a large hierarchies can lead to slower React performance since you may end up causing more of your app to rerender than you anticipated.

Using a list

If you care about the order of items, use a list. Much like maps, lists allow users to make changes in parallel without needing to wait on a server to coordinate each and every change. Changes are made fist on the browser and then sent over the network.

To use a list, you use a hook:

// "todos" is a pure javascript array.
// "list" is an instance of a class with functions you can use to manipulate the
// list. It's `undefined` until the initial load of the room is complete
const [todos, list] = useList("myroom", "todos");

When you modify the list, you'll update the pure javascript array and send an update to everyone else in the room:

// Insert a number of items at the end of the list
list?.push("cat", "two", "three");

// Insert an item after an index
list?.insertAfter(2, "four");

// Insert an item at an index, shifting
// other items if needed.
list?.insertAt(0, "zero");

// Delete an item at an index
list?.delete(3);

// Update an item at an index
list?.set(0, "root");

Conflict resolution without race conditions

Room Service's lists save you from many different classes of concurrency bugs while preserving the intent of the user.

For example, let's walk through one of these bugs that happen in a normal list. Say we had a list like this:

["dog", "cat", "bird", "bat"];

And two users on seperate browsers try to make modifications to the list at the same time. Alice tries to change the 2nd item.

list[2] = "mouse"; // Alice changes the 2nd item

And Berrry tries to delete the first item.

delete list[0]; // Berry deletes the 0th item

If Berry goes first, then the 2nd item is "bat", not "bird", and Alice's change ends up affecting the wrong item.

But in a Room Service list, both of these changes succeed. Berry can delete the first item in the list:

list.delete(0);

Alice can modify the second item:

list.set(2, "mouse");

And the result is what you'd expect. Both users succeed at their intent. Alice changes "bat" to "mouse", Berry deletes the first item.

["cat", "bird", "mouse"];

It's built to go fast.

Changes are optimistically updating with low-rate-limits & low-latency targets, so they're safe to use for frequently updating changes such as positions of objects on the screen:

function MoveableBlock(props) {
  const [block, map] = useMap("myroom", props.blockID);

  useEffect(() => {
    function onMouseMove(e) {
      map?.set("position", {
        x: e.clientX,
        y: e.clientY,
      });
    }

    document.addEventListener("mousemove", onMouseMove);
    return () => document.removeEventListener("mousemove", onMouseMove);
  }, []);

  return (
    <div
      style={{
        top: block.position.x,
        left: block.position.y,
        position: "absolute",
        width: 50,
        height: 50,
        background: "red",

        // You'll still want some amount of animation between
        // updates. Room Service can't go faster than
        // your internet speed.
        transition: "all 0.25s",
      }}
    />
  );
}

Using Presence

Room Service also comes with Presence, an expiring key-value store that's scoped to a user. When the user leaves a room, their data is deleted. That makes is great for things like "Who's in the room" presence indicators, live cursors, or shared highlighting effects.

To use presence, use a hook:

const [joined, joinedClient] = usePresence("myroom", "joined");

useEffect(() => {
  joinedClient.set(true);
}, []);

// { "your-user-id": true }
console.log(joined);

Presence returns an object where the key is the user id you provide in your Auth Webhook, and the value is whatever you set for that presence key.

Use the same object anywhere in your application

Maps, Lists, and Presence can be accessed anywhere in your React tree without triggering a global rerender, much like Recoil or Jotai.

For example, say you wanted to access the same data in your canvas and sidebar. You could put the hooks in a root component, and then pass the data down to both components like this:

// Slow, don't actually do this!
function Root() {
  const [item, map] = useMap("room", "myitem");

  return (
    <div>
      <Sidebar item={item} />
      <Canvas item={item} />
    </div>
  );
}

But if you do this, then any change to this map will rerender the whole Canvas and Sidebar. In a complex application this can cause noticeable performance issues.

Instead, you can put the hook only on the component only on the component that needs it, like an individual item in your sidebar, or an individual object in your canvas:

function SidebarItem() {
  const [item, map] = useMap("room", "myitem");

  // ...
}

function CanvasItem() {
  const [item, map] = useMap("room", "myitem");

  // ...
}

When you change something in your SidebarItem, it will update the CanvasItem:

function SidebarItem() {
  const [item, map] = useMap("room", "myitem");

  function changeColor() {
    map?.set("color", "blue");
  }

  // ...
}

function CanvasItem() {
  const [item, map] = useMap("room", "myitem");

  item.color; // "blue"

  // ...
}

But it won't rerender your whole tree on every single change!

Made with 🌁 in San Francisco

hello@roomservice.dev

About Us

Copyright @ 2021

Room Service