ReactSharedPresence

Using useSharedPresence

A hook that allows clients to write signaling information about themselves and to read signaling information about all other clients.

Accessing & Updating Own State

self.state and setState mostly acts like React's useState

import {useSharedPresence, useSharedState } from '@airstate/react';

export function App() {
    const { self, setState } = useSharedPresence({
        // also knows as the `peerId`; this is required.
        peer: 'tomato',

        // can be of any JSON serializable type
        initialState: 'chilling'
    });

    const toggleState = () => {
        setState((state) => {
            return state === 'chilling'
                ? 'stressing'
                : 'chilling'
        });
    }

    return (
        <button onClick={toggleState}>
            {self.state ?? 'unknown'}
        </button>
    );
}

Another peer in the same room will find the value "chilling" when they access others['tomato'].state (see "Accessing Other Peer States" below)

Note: Both self.state and others[peerId].state can be undefined, so it is recommended to write defensive code. This is to account for the fact that clients may set their state late or may have a parse error in the validator (see "Validating State" below).

Choosing peer

We chose the key peer because it looks nicer in the code, but it's often beneficial to refer to peer as "peer id"

While it is tempting to set the peer id to be the username or user id, we highly discourage doing that because the same user might be logged in from two devices.

Prefer to use a unique device id or login session id if available.

Accessing Other Peers

Listing Peers

others is an object of type Record<string, TPeer> where each key is the peer set by the peer's client.

const { others } = useSharedPresence({
    peer: 'tomato',
    initialState: true
});

const allPeerIds: string[] = Object.keys(others);

// alternatively; all `TPeer`
// has the `peer` property

const allPeerIdsAlt: string[] =
    Object.values(others).map(other => other.peer);

Peer State

TPeer has a property called state that is of type T | undefined where T is the generic type or the type of initialState

const { others } = useSharedPresence<string>({
    peer: 'tomato',

    // has to be a string as the generic
    // argument is `string`
    initialState: 42
});


// assuming there's another peer `potato`
const specificPeer = others['potato'];

// assuming they set
// their state as `69`
specificPeer.state === 69;

// time when `state` was last updated
// (server-time in milliseconds since unix epoch)
specificPeer.lastUpdated;

Peer Connection State

TPeer also has properties that allow you to access connectivity states of the peers.

const { others } = useSharedPresence({
    peer: 'tomato',
    initialState: {}
});

// assuming there's another peer `potato`
const specificPeer = others['potato'];

// `connected` has the current
// connection state of the peer
specificPeer.connected        // boolean

// these are both milliseconds
// since unix epoch
specificPeer.lastConnected    // number | undefined
specificPeer.lastDisconnected // number | undefined

Defining Room

By default, the unique identifier for this presence "room" is set to be the pathname derived from window.location.href

An important limitation of this is that you can only have at most one SharedPresence instance without an explicitly defined room on a page.

To define which room to join, add the room key to the hook's options object.

const { others } = useSharedPresence({
    peer: 'PEER ID',

    room: 'ROOM ID',

    initialState: [1, 2, 3]
});

We chose the key room so that it looks nicer in the code, but it's often beneficial to refer to room as the roomId

Choosing room

It's easy to think of the room as the unique document or unique page that is being collaborated on. It's not unusual to set the document_id or page pathname as the room.

It's also a common practice to prefix the room id with the type of document like stories:[STORY ID]

Validating State

As state can be set by the client, it's possible to break your code accidentally by writing the wrong shape of state from two different components.

In the below example we're using zod, but you can welcome to use any schema validation library to validate the state. You can even use no library at all, as long as you throw an error if validation fails.

import z from 'zod';

export function App() {
    const { others } = useSharedPresence({
        peer: 'PEER ID',
        room: 'ROOM ID',

        initialState: 'busy',

        validate: (rawState: any): string => {
            return z.enum(['busy', 'away', 'active']).parse(rawState);
        }
    });

    return (
        <div>
        {
            Object.values(others).map(other => (
                <div>
                {other.peer}: {other.error ? other.error.message : 'NO ERROR'}
                </div>
            ))
        }
        </div>
    );
}

Each TPeer object has an error property of type any | undefined

Handling Errors

If there ever happens with the underlying mechanisms, error would be not null nor undefined.

const { error } = useSharedPresence({
    peer: 'PEER ID',
    room: 'ROOM ID',

    initialState: '🤒'
});

console.assert(state === 'Hello');

if (error) {
    // do something with `error`
}

The error property above will only contain the last error about the connection and not validation, and will be cleared as soon as another "good" state is reached. If you want to persist errors, or if you want to externally handle validation errors in some global manner you are welcome to use the onError option to DIY it.

const [storedErrors, setStoredErrors] = useState<{ peer: string; error: any }[]>([]);

const {} = useSharedPresence({
    peer: 'PEER ID',
    room: 'ROOM ID',

    initialState: '🤒',

    validate: (rawState) => {
        if (typeof rawState !== 'string') {
            throw new Error('state must be a string');
        }

        return rawState;
    },

    onError(error: any, peer: string) {
        setStoredErrors(
            errors => [
                ...errors,
                { peer: peer, error: error }
            ]
        );
    }
});

Underlying Connection Info

Sometimes it is necessary to be able to retrieve and act on the state of the underlying mechanisms involved.

export function App() {
    const {

        connected,
        started,
        error

    } = useSharedPresence<string>({
        peer: 'PEER ID',
        initialState: 'chilling',
        room: 'ROOM ID',
    });
}
KeyTypeDescription
connectedbooleantrue if the underlying client connection is open.
startedbooleantrue if the underlying subscription on the server for room is active.
erroranyThe most recent error in the underlying connection or subscription

Note: Unlike SharedState, error in the above snippet only contains errors experienced by the underlying connection and/or subscription.

The validation errors for individual peer state is included under each peer as others[peerId].error

Deferring Sync Start

Sometimes you might want to enable syncing after a delay maybe because you want to derive the room or peer via an asynchronous process. For such instances you may use the enabled option.

const [room, setRoom] = useState<string | null>(null);
const [peer, setPeer] = useState<string | null>(null);

useEffect(() => {

    // for illustrative purposes of course.

    setTimeout(() => {
        setPeer('mark.s@lumon.com');
        setRoom('relaxing');
    }, 1000);

}, []);

const {self, setState, others} = useSharedPresence({
    initialState: 'grinding',

    peer: peer!,
    room: room!,

    enabled: room !== null && peer !== null
});

room and peer is only read once when the subscription is being started, hence the room and peer is read once when you set enabled to true. Changing room or peer after the fact will not change the room being subscribed to, nor the peer id presented to the server.

Tokens & Security

By default, AirState servers allow all clients to join or advertise signaling information on any room, but that can be locked down via config. If the default permissions don't permit ad-hoc joins, your backend will need to provide a signed token.

Tokens can also specify exactly which room the client can join such that clients cannot use discovered room ids to gain public access to the presence state.

You can pass this signed token from your backend to the useSharedPresence hook via the token option.

Literal String

// you can pass the token directly if
// you already have it

const {self, setState, others} = useSharedPresence<string>({
    peer: 'PEER ID',
    initialState: 'chilling',

    token: 'TOKEN STRING'
});

Function

// you can pass a function that returns
// the (latest) token

const {self, setState, others} = useSharedPresence<string>({
    peer: 'PEER ID',
    initialState: 'chilling',

    token: () => { 
        return 'TOKEN STRING'; 
    } 
});

Async Function

// that function can be async
// if you want

const {self, setState, others} = useSharedPresence<string>({
    peer: 'PEER ID',
    initialState: 'chilling',

    token: async () => { 
        const req = await fetch('/token/route'); 
        const resBody = await req.json(); 

        return resBody.token; 
    } 
});

Peer Meta

All TPeer objects also has a meta property. This property cannot be written to directly by the clients. Rather this is set via the token (mentioned above), i.e. only your application server can sign a token with the meta information encoded as a JWT along with the permissions.

Read "Setting Presence Meta" under "Security" for better understanding.

Peer meta is mostly used to signal immutable and trusted data to other peers. Typically, servers sign tokens that contain metadata about the user like username or name or picture.

Custom Client

By default, AirState's hooks will use the "default client" that is created the first time you invoke a subscription. This client is global and not easy to re-configure once already configured.

Having said that, you can still pass a different client created with createClient via the client option.

import {createClient} from "@airstate/client";
import {useSharedPresence} from "@airstate/react";

const differentClient = createClient({
    // client options
});

export function App() {
    const {self, setState, others} = useSharedPresence({
        peer: 'PEER ID',

        client: differentClient,

        initialState: {
            playing: false,
            game: null
        }
    });

    return <></>;
}