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',
});
}
Key | Type | Description |
---|---|---|
connected | boolean | true if the underlying client connection is open. |
started | boolean | true if the underlying subscription on the server for room is active. |
error | any | The 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 <></>;
}