iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚀

[React/Next.js] Real-time Collaboration Development with Liveblocks

に公開

What is Liveblocks?

Liveblocks | Collaborative experiences in days, not months

Liveblocks provides a complete toolkit for developers to incorporate high-performance collaboration features into their products extremely quickly.

I'm going to sign up right away and try it out.

image1.png

After creating an account and moving to the dashboard, you'll find that two projects, Development and Production, have already been created.

Additionally, the MAU and the number of concurrent connections within a single room are also displayed.

image2.png

Base Implementation of the Whiteboard

I'm planning to build a Whiteboard + React app this time, so I'll follow the tutorial below.

Tutorials - Creating a collaborative online whiteboard with React and Liveblocks | Liveblocks documentation

Operating Environment

"dependencies": {
    "@liveblocks/client": "^1.2.1",
    "@liveblocks/react": "^1.0.9",
    "next": "13.4.13",
    "react": "18.2.0",
    "react-dom": "18.2.0"
  },
  "devDependencies": {
    "@types/node": "20.4.8",
    "@types/react": "^18.2.6",
    "@types/react-dom": "^18.2.4",
    "eslint": "8.46.0",
    "eslint-config-next": "13.4.13",
    "prettier": "^2.8.8",
    "typescript": "^5.0.4"
  }

Obtaining the API Key

First, obtain the API key you will use in advance. Both Public and Secret keys are available, but for this tutorial, I'll proceed with the Public key.

Public and Secret Keys
Secret keys allow you to control who can access the room. While more secure, they require your own backend endpoint. In this tutorial, we will use a Public key. For more details, refer to the Authentication Guide.

Navigate to the Development project and click on "API keys".

image3.png

Take note of the "Public key" and set it in .env.local as shown below.

NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY=pk_xxxxxxxxxxxxxx

Installing Required Packages

yarn add @liveblocks/client @liveblocks/react

Create providers/liveblocks.ts with the following content:

import { createClient } from '@liveblocks/client';

const client = createClient({
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY ?? '',
});

Creating a Room

Using createRoomContext from @liveblocks/react, we'll create a RoomProvider and hooks so they can be easily consumed from components. We'll add this to the providers/liveblocks.ts file mentioned above.

import { createClient } from '@liveblocks/client';
import { createRoomContext } from '@liveblocks/react'; // Added

const client = createClient({
  publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY ?? '',
});

export const { RoomProvider } = createRoomContext(client); // Added

Next, apply the RoomProvider in app/layout.tsx.

'use client';

import { RoomProvider } from './liveblocks.config'; // Added

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <head></head>
      <body>
        {/* Wrap with RoomProvider */}
        <RoomProvider id="react-whiteboard-app" initialPresence={{}}>
          <div>{children}</div>
        </RoomProvider>
      </body>
    </html>
  );
}

All components wrapped in RoomProvider will now have access to special React hooks used to interact with this room.

Creating the Canvas

Using LiveMap, we will store a map of shapes within the room's storage.

LiveMap is a type of storage provided by Liveblocks. LiveMap is similar to a JavaScript Map, but its items are synchronized in real-time across different clients. Even if multiple users insert or delete items simultaneously, LiveMap maintains consistency for all users in the room.

Initialize the storage using the initialStorage prop of RoomProvider.

'use client';

import { LiveMap } from '@liveblocks/client'; // Added
import { RoomProvider } from './liveblocks.config';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <head></head>
      <body>
        <RoomProvider
          id="react-whiteboard-app"
          initialPresence={{}}
          initialStorage={{ // Added
            shapes: new LiveMap(),
          }}
        >
          <div>{children}</div>
        </RoomProvider>
      </body>
    </html>
  );
}

By using useMap from createRoomContext, you can access the storage saved within Liveblocks.

export const { RoomProvider, useMap } = createRoomContext(client);

useMap returns null while connecting, so it's a good idea to display an indicator if it is null.

Create app/page.tsx with the following content:

'use client';

import { useMap } from '../providers/liveblocks';

const Rectangle = ({ shape }: { shape: any }) => {
  const { x, y, fill } = shape;

  return (
    <div
      className="rectangle"
      style={{
        transform: `translate(${x}px, ${y}px)`,
        backgroundColor: fill ? fill : '#CCC',
      }}
    ></div>
  );
};

const Canvas = ({ shapes }: { shapes: any }) => {
  return (
    <>
      <div className="canvas">
        {Array.from(shapes, ([shapeId, shape]) => {
          return <Rectangle key={shapeId} shape={shape} />;
        })}
      </div>
    </>
  );
};

const PageWrapper = () => {
  const shapes = useMap('shapes');

  if (shapes == null) {
    return <div className="loading">Loading</div>;
  }
  return <Canvas shapes={shapes} />;
};

const Page = () => {
  return <PageWrapper />;
};
export default Page;

Finally, create app/global.css with the following content and import it in app/layout.tsx.

body {
  background-color: #eeeeee;
}

.loading {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
  width: 100vw;
}

.canvas {
  background-color: #eeeeee;
  touch-action: none;
  width: 100vw;
  height: 100vh;
}

.rectangle {
  position: absolute;
  /* transition: all 0.1s ease; */
  stroke-width: 1;
  border-style: solid;
  border-width: 2px;
  height: 100px;
  width: 100px;
}

.toolbar {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  padding: 4px;
  border-radius: 8px;
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  display: flex;
  background-color: #ffffff;
  user-select: none;
}

.toolbar button {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 4px 8px;
  border-radius: 4px;
  background-color: #f8f8f8;
  color: #181818;
  border: none;
  box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 0px 0px 1px rgba(0, 0, 0, 0.05);
  margin: 4px;
  font-weight: 500;
  font-size: 12px;
}

.toolbar button:hover,
.toolbar button:focus {
  background-color: #ffffff;
}

.toolbar button:active {
  background-color: #eeeeee;
}

app/layout.tsx

import './globals.css';

Enabling Adding Rectangles

Add the following to app/page.tsx as instructed in the tutorial.

const COLORS = ['#DC2626', '#D97706', '#059669', '#7C3AED', '#DB2777'];

const getRandomInt = (max: number) => {
  return Math.floor(Math.random() * max);
};

const getRandomColor = () => {
  return COLORS[getRandomInt(COLORS.length)];
};

// ...

const Canvas = ({ shapes }: { shapes: any }) => {
  // Added insertRectangle
  const insertRectangle = () => {
    const shapeId = Date.now().toString();
    const rectangle = {
      x: getRandomInt(300),
      y: getRandomInt(300),
      fill: getRandomColor(),
    };
    shapes.set(shapeId, rectangle);
  };
  return (
    <>
      <div className="canvas">
        {Array.from(shapes, ([shapeId, shape]) => {
          return <Rectangle key={shapeId} shape={shape} />;
        })}
      </div>
      {/* ↓ Added */}
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
      </div>
    </>
  );
};
// ...

image4.png

A "Rectangle" button has been added, and you can now add rectangles by clicking it!

Adding Selection

We will use the new hooks useMyPresence and useOthers.

export const { RoomProvider, useMap, useOthers, useMyPresence } =
  createRoomContext(client);

We'll set up events for clicking a Rectangle and configure it to display a selection color.

const Rectangle = ({
  shape,
  id,
  onShapePointerDown,
  selectionColor,
}: {
  shape: any;
  id: string;
  onShapePointerDown: (e: any, id: string) => void;
  selectionColor: string | undefined;
}) => {
  const { x, y, fill } = shape;

  return (
    <div
      className="rectangle"
      onPointerDown={(e) => onShapePointerDown(e, id)}
      style={{
        transform: `translate(${x}px, ${y}px)`,
        backgroundColor: fill ? fill : '#CCC',
        borderColor: selectionColor || 'transparent',
      }}
    ></div>
  );
};

We'll update Canvas to reflect the selection state using useMyPresence and useOthers.

const Canvas = ({ shapes }: { shapes: any }) => {
  const [{ selectedShape }, setPresence] = useMyPresence();
  const others = useOthers();

  const insertRectangle = () => {
    const shapeId = Date.now().toString();
    const rectangle = {
      x: getRandomInt(300),
      y: getRandomInt(300),
      fill: getRandomColor(),
    };
    shapes.set(shapeId, rectangle);
  };

  const onShapePointerDown = (e: any, shapeId: string) => {
    setPresence({ selectedShape: shapeId });
    e.stopPropagation();
  };

  return (
    <>
      <div
        className="canvas"
        onPointerDown={(e) => setPresence({ selectedShape: null })}
      >
        {Array.from(shapes, ([shapeId, shape]) => {
          const selectionColor =
            selectedShape === shapeId
              ? 'blue'
              : others.some((user) => user.presence?.selectedShape === shapeId)
              ? 'green'
              : undefined;
          return (
            <Rectangle
              key={shapeId}
              shape={shape}
              id={shapeId}
              onShapePointerDown={onShapePointerDown}
              selectionColor={selectionColor}
            />
          );
        })}
      </div>
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
      </div>
    </>
  );
};

image5.gif

Deleting Rectangles

Add the following to Canvas.

const deleteRectangle = () => {
    shapes.delete(selectedShape);
    setPresence({ selectedShape: null });
  };
// ...
return (
    <>
      ...
      <div className="toolbar">
        <button onClick={insertRectangle}>Rectangle</button>
        {/* Add the following */}
        <button onClick={deleteRectangle} disabled={selectedShape == null}>
          Delete
        </button>
      </div>
    </>
  );

image6.gif

Synchronizing Rectangle Movement

We will modify Canvas.

const Canvas = ({ shapes }: { shapes: any }) => {
  const [isDragging, setIsDragging] = useState(false); // Added
  const [{ selectedShape }, setPresence] = useMyPresence();
  const others = useOthers();

  const onShapePointerDown = (e: any, shapeId: string) => {
    e.stopPropagation();
    setPresence({ selectedShape: shapeId });
    setIsDragging(true); // Added
  };
  // ↓Added
  const onCanvasPointerUp = (e: any) => {
    if (!isDragging) {
      setPresence({ selectedShape: null });
    }

    setIsDragging(false);
  };
  // ↓Added
  const onCanvasPointerMove = (e: any) => {
    e.preventDefault();

    if (isDragging) {
      const shape = shapes.get(selectedShape);
      if (shape) {
        shapes.set(selectedShape, {
          ...shape,
          x: e.clientX - 50,
          y: e.clientY - 50,
        });
      }
    }
  };

  return (
    <>
      <div
        className="canvas"
        onPointerDown={(e) => setPresence({ selectedShape: null })}
        onPointerMove={onCanvasPointerMove} // Added
        onPointerUp={onCanvasPointerUp} // Added
      >
      ...
      </div>
    </>
  );
};

image7.gif

Summary

Trying it out this time, I found it very appealing that real-time collaboration features can be easily integrated and that a data store using CRDTs is available for use.
Also, since there is a limit on MAU per room, I think it will be necessary to take that into account when incorporating it.

The code I tried this time is uploaded to the following repository.

Slowhand0309/liveblocks-example: Liveblocks whiteboard + React example.

Discussion