TL;DR
I built a multi-user, end-to-end, server-authoritative chat app in react without writing a single line of back-end code.
π©βπ»Wonder how? Let's start!
I'm choosing next.js for simplicity but this can work with create-react-app or any other react framework.
npx create-next-app
Just click enter multiple times to create the project.
I don't need the fancy Next.JS's new App router, so I will use the old pages folder, but feel free to do it your way.
Step 1: The App Logic
We're starting by creating the reducer file where the chat logic will live, at src/components/chat/reducer.ts
, and later on "feed" it into a useReducer
hook which will allow us to dispatch actions and listen to its state changes.
The reducer file looks like this:
//path=src/components/chat/reducer.ts
type Action<
TType extends string,
TPayload = undefined
> = TPayload extends undefined
? {
type: TType;
}
: {
type: TType;
payload: TPayload;
};
// PART 1: State Type and Initial State Value
export const userSlots = {
pink: true,
red: true,
blue: true,
yellow: true,
green: true,
orange: true,
};
export type UserSlot = keyof typeof userSlots;
export type ChatMsg = {
content: string;
atTimestamp: number;
userSlot: UserSlot;
};
export type ChatState = {
userSlots: {
[slot in UserSlot]: boolean;
};
messages: ChatMsg[];
};
export const initialChatState: ChatState = {
userSlots,
messages: [],
};
// PART 2: Action Types
export type ChatActions =
| Action<
'join',
{
userSlot: UserSlot;
}
>
| Action<
'leave',
{
userSlot: UserSlot;
}
>
| Action<
'submit',
{
userSlot: UserSlot;
content: string;
atTimestamp: number;
}
>;
// PART 3: The Reducer β This is where all the logic happens
export default (state = initialChatState, action: ChatActions): ChatState => {
// User Joins
if (action.type === 'join') {
return {
...state,
userSlots: {
...state.userSlots,
[action.payload.userSlot]: false,
},
};
}
// User Leaves
else if (action.type === 'leave') {
return {
...state,
userSlots: {
...state.userSlots,
[action.payload.userSlot]: true,
},
};
}
// Message gets submitted
else if (action.type === 'submit') {
const nextMsg = action.payload;
return {
...state,
messages: [...state.messages, nextMsg],
};
}
return state;
};
What exactly happens here?
For a quick reminder on how React useReducer works check the official React Docs.
The logic is simple and straightforward for this tutorial. Each user has to pick a slot (i.e. color) before entering the chat window. We keep a dictionary of the slots in the userSlots
field, and we flag them true
or false
based on whether they are available or not. An available slot is true
.
The messages
field keeps the message history in the order of submission.
We only need 3 actions for now, "join", "leave" and "submit". The first two are simply responsible for flagging the userSlot
and the last one appends a new message to the history (i.e. messages
field).
This is pure functional programming by the way, and follows the same API as Redux or useReducer.
Step 2: The UI
For this tutorial, we'll build a pretty simple UI, enhanced by the help of tailwind, but we shouldn't spend time focusing on that as it isn't part of the scope here, so feel free to just copy and paste this part in the appropriate place according to the "path" at the beginning of each new component file.
We'll add 3 components:
1. Chat Onboarding Component
This is a simple component that allows users to pick a slot (color), before entering the ChatBox View.
//path=src/components/chat/ChatOnboarding.tsx
type Props = {
slots: string[];
onSubmit: (slot: string) => void;
};
export const ChatOnboarding: React.FC<Props> = ({ slots, onSubmit }) => {
return (
<div
className="fixed nohidden inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex text-slate-900"
id="my-modal"
>
<div className="relative p-8 bg-white w-full max-w-md m-auto flex-col flex rounded-lg">
<h2 className="text-xl font-bold">Pick a slot</h2>
<div className="flex flex-row justify-between pt-5">
{slots.map((slot) => (
<button
className="text-center group"
key={slot}
onClick={() => onSubmit(slot)}
>
<div
className="rounded-full"
style={{
backgroundColor: slot,
width: 50,
height: 50,
}}
/>
<span className="text-md invisible group-hover:visible">{slot}</span>
</button>
))}
</div>
</div>
</div>
);
};
2. Chat Box component
This is the component that displays the message history and is responsible for submitting new messages.
It is pretty long, but nothing special happens here other than displaying the html nicely, and calling an onSubmit
handler when the "Submit" button is clicked.
Feel free to read through it or just copy and paste it for now!π
//path=src/components/chat/ChatBox.tsx
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ChatMsg, UserSlot } from './reducer';
import { useRouter } from 'next/router';
type Props = {
userSlot: UserSlot;
messages: ChatMsg[];
onSubmit: (msg: ChatMsg) => void;
};
export const ChatBox: React.FC<Props> = ({ userSlot, messages, onSubmit }) => {
const router = useRouter();
const [msg, setMsg] = useState<string>();
const submit = useCallback(() => {
if (msg?.length && msg.length > 0) {
onSubmit({
content: msg,
atTimestamp: new Date().getTime(),
userSlot,
});
setMsg('');
}
}, [msg, userSlot, onSubmit]);
const messagesInDescOrder = useMemo(
() => [...messages].sort((a, b) => b.atTimestamp - a.atTimestamp),
[messages]
);
// Invitation Copy logic
const [inviteCopied, setInviteCopied] = useState(false);
useEffect(() => {
if (inviteCopied === true) {
setTimeout(() => {
setInviteCopied(false);
}, 2000);
}
}, [inviteCopied]);
return (
<div className="flex text-slate-900">
<div
style={{
height: 600,
width: 300,
}}
>
<div className="text-right">
Me =
<span
style={{
color: userSlot,
}}
>
{' ' + userSlot}
</span>
</div>
<div
className="bg-slate-100 w-full mb-3 flex rounded-lg"
style={{
height: 'calc(100% - 60px + 1em)',
flexDirection: 'column-reverse',
overflowY: 'scroll',
scrollBehavior: 'smooth',
}}
>
{messagesInDescOrder.map((msg) => (
<div
key={msg.atTimestamp}
className={`p-3 pt-2 pb-2 border-solid border-t border-slate-300 last:border-none ${
msg.userSlot === userSlot && 'text-right'
}`}
>
<div>{msg.content}</div>
<i style={{ fontSize: '.8em', color: msg.userSlot }}>
by "{msg.userSlot}" at{' '}
{new Date(msg.atTimestamp).toLocaleString()}
</i>
</div>
))}
</div>
<textarea
value={msg}
onChange={(e) => setMsg(e.target.value)}
className="p-2 w-full rounded-lg"
style={{
height: '60px',
}}
/>
<div className="flex justify-between">
<button
className="bg-green-300 hover:bg-green-500 text-black font-bold py-2 px-4 rounded-lg"
onClick={() => {
const pathWithoutQuery = router.asPath.slice(
0,
router.asPath.indexOf('?')
);
navigator.clipboard.writeText(
window.location.origin + pathWithoutQuery
);
setInviteCopied(true);
}}
>
{inviteCopied ? 'Copied' : 'Invite Friend'}
</button>
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded-lg"
disabled={!!(msg?.length && msg.length === 0)}
onClick={submit}
>
Submit
</button>
</div>
</div>
</div>
);
};
3. ChatBoxContainer Component
This is a wrapper around the ChatBox Component and takes care of dispatching the right action at the right event.
//path=src/components/chat/ChatBoxContainer.tsx
import { Dispatch, useEffect } from 'react';
import { ChatBox } from './ChatBox';
import { ChatActions, ChatState, UserSlot } from './reducer';
type Props = {
userSlot: UserSlot;
state: ChatState;
dispatch: Dispatch<ChatActions>;
};
export const ChatBoxContainer: React.FC<Props> = ({
dispatch,
state,
userSlot,
}) => {
// The user "joins" and "leaves" on mount and unmount
// for simplicity' sake but of course this can be
// changed based on other UX requirements.
useEffect(() => {
// Join as soon as the component mounts
dispatch({
type: 'join',
payload: {
userSlot,
},
});
return () => {
// Leave as soon as the component umounts
dispatch({
type: 'leave',
payload: {
userSlot,
},
});
};
}, [userSlot]);
return (
<ChatBox
messages={state.messages}
userSlot={userSlot}
onSubmit={(msg) => {
// Submit the message
dispatch({
type: 'submit',
payload: msg,
});
}}
/>
);
};
Wonder why this component gets
state
&dispatch
in theprops
rather than usinguseReducer
directly? Make sure to read till the end of the article to find out.
Step 3: Hook up the UI with the logic
//path=src/pages/index.tsx
import reducer, { UserSlot, initialChatState } from '@/components/chat/reducer';
import { ChatBoxContainer } from '@/components/chat/ChatBoxContainer';
import { useReducer } from 'react';
import { ChatOnboarding } from '@/components/chat/ChatOnboarding';
import { useRouter } from 'next/router';
export const objectKeys = <O extends object>(o: O) =>
Object.keys(o) as (keyof O)[];
export default function () {
const router = useRouter();
const { slot } = router.query;
const [state, dispatch] = useReducer(reducer, initialChatState);
if (slot) {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24 bg-slate-600">
<ChatBoxContainer
userSlot={slot as UserSlot}
state={state}
dispatch={dispatch}
/>
</main>
);
}
// Filter out the taken User Slots
const availableUserSlots = objectKeys(state.userSlots).reduce(
(accum, nextSlot) =>
state.userSlots[nextSlot] ? [...accum, nextSlot] : accum,
[] as UserSlot[]
);
return (
<ChatOnboarding
slots={availableUserSlots}
onSubmit={(slot) => {
// Redirect to the same page with the selected "slot"
router.push({
pathname: router.asPath,
query: { slot },
});
}}
/>
);
}
What happens here?
First, we look for a given slot
in the url query params. If it's there simply render the ChatBoxContainer
, otherwise show the ChatOnboarding
Component and allow the user to select a slot.
Secondly, and more importantly we are feeding the reducer created above into a useReducer
hook, which gives us the ability to dispatch
actions and use the returned state
.
If you run your app now (yarn dev
) and go to http://localhost:3000
, you should see the Chat Onboarding Slot picker Dialog π
and upon picking one, you'll be redirected to the ChatBox
View where you can type in your message and the history appears like below.
Done, right?
Wait, what about the Multiplayer Part?
Oh yeah! I almost forgot. π«’
Normally, this would be the most difficult part as it involves a handful of important decisions to be taken and a few different pieces of the puzzle to be put at work together βΒ a data store (redis, postgres, etc.), the network logic and protocols (websockets, rest, p2p, etc.), a back-end framework, the back-end code and of course the server deployment and hosting. That's quite a lot, isn't it? π
Luckily we can use Movex, which handles all of these out of the box as well as the state management on the front-end.
What the "X" is Movex? π π§
Movex is a "predictable state container*" for multiplayer applications.
Server Authoritative by nature. No Server hassle by design. It comes with Realtime Sync and Secret State out of the box.The best part is that there is no need to worry about the back-end. Really! You just write font-end code using any of the JS/TS frameworks or game engines and Movex will takes care of the back-end seamlessly.
See more on how at https://www.movex.dev.
Also, Movex is a very new library and I am the only developer for now so I would really appreciate it a lot if you could give it a star and let me know if you find it useful in the comments below! π https://github.com/movesthatmatter/movex.
Step 4: How to add Movex to the React app
yarn add movex movex-react movex-core-util; yarn add --dev movex-service
Add a movex.config file
This ties in the chat reducer with a Movex Resource and enables it to run the back-end code without you having to do anything extra.
//path=src/movex.config.ts
import chatReducer from './components/chat/reducer';
export default {
resources: {
chat: chatReducer,
},
};
What is going on here?
We let Movex know we have a resource
called "chat" and we assigned it a reducer. Movex will then run the reducer on the font-end as well as on the back-end and by using Deterministic Action Propagation it will seamlessly be able to sync-up the state on all the clients. Ta Daaaa.π₯³
Wrap the App with MovexProvider
Change the src/pages/_app.tsx
file to look like this:
//path=pages/_app.tsx
import movexConfig from '@/movex.config';
import '@/styles/globals.css';
import { MovexProvider } from 'movex-react';
import type { AppProps } from 'next/app';
export default function App({ Component, pageProps }: AppProps) {
return (
<MovexProvider
movexDefinition={movexConfig}
endpointUrl="localhost:3333"
>
<Component {...pageProps} />
</MovexProvider>
);
}
What happens here?
We simply wrap the whole App in the <MoveProvider>
, as there will be multiple pages needing it. This is similar to the ReduxProvider, except we give it an endpointUrl
which is the url where Movex runs the back-end server.
It also, takes the just created "movex.config" file in to be able to create the hooks for the configured resources on the back-end.
Bring Movex to the Index file as well
Change the index file to look like this:
//path=src/pages/index.tsx
import { useMovexResourceType } from 'movex-react';
import { initialChatState } from '@/components/chat/reducer';
import { toRidAsStr } from 'movex';
import { ChatOnboarding } from '@/components/chat/ChatOnboarding';
import { useRouter } from 'next/router';
import movexConfig from '@/movex.config';
export default function () {
const router = useRouter();
const chatResource = useMovexResourceType(movexConfig, 'chat');
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
{chatResource ? (
<ChatOnboarding
slots={Object.keys(initialChatState.userSlots)}
onSubmit={(slot) => {
chatResource.create(initialChatState).map((item) => {
router.push({
pathname: `/chat/${toRidAsStr(item.rid)}`,
query: { slot },
});
});
}}
/>
) : (
<div>waiting...</div>
)}
</main>
);
}
What happens here?
First, the thinking here is to split the UI/UX in the previous index.tsx
version in two separate pages.
- The
/pages/index
will be responsible for the Chat Onboarding - A new
/pages/chat/[rid]
will display the ChatBox itself and will get the chat resource id (rid) and user slot in the URL query params. This is the Chat Room Page.
Secondly, we start using the useMovexResourceType
hook at the begining of the component, which gives us a MovexResource
Object, and then we use that later to create an actual chat resource, once the user has picked a slot.
From there, we use the chat resource identifier (aka rid) to redirect to /pages/chat/[rid]
.
The Chat Room
This is where the Chat UI/UX actually lives, and it's specific to a chat resource id. Meaning, each new resource will have its own state (message history, etc.) and the users will be able to access multiple chat rooms at the same time.
//path=src/pages/chat/[rid].tsx
import { MovexBoundResource } from 'movex-react';
import { ChatBoxContainer } from '@/components/chat/ChatBoxContainer';
import { useRouter } from 'next/router';
import { isRidOfType } from 'movex';
import { ChatOnboarding } from '@/components/chat/ChatOnboarding';
import { objectKeys } from 'movex-core-util';
import { UserSlot } from '@/components/chat/reducer';
import movexConfig from '@/movex.config';
export default function () {
const router = useRouter();
const { rid, slot } = router.query;
// If the given "rid" query param isn't an actual rid of type "chat"
if (!isRidOfType('chat', rid)) {
return <div>Error - Rid not valid</div>;
}
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24 bg-slate-600">
<MovexBoundResource
movexDefinition={movexConfig}
rid={rid}
render={({ boundResource: { state, dispatch } }) => {
// If there is a given slot just show the ChatBox
// Otherwise allow the User to pick one
if (slot) {
return (
<ChatBoxContainer
userSlot={slot as UserSlot}
state={state}
dispatch={dispatch}
/>
);
}
// Filter out the taken User Slots
const availableUserSlots = objectKeys(state.userSlots).reduce(
(accum, nextSlot) =>
state.userSlots[nextSlot] ? [...accum, nextSlot] : accum,
[] as UserSlot[]
);
return (
<ChatOnboarding
slots={availableUserSlots}
onSubmit={(slot) => {
// Redirect to the same page with the selected userSlot
router.push({
pathname: router.asPath,
query: { slot },
});
}}
/>
);
}}
/>
</main>
);
}
What does the code do?
Other than the query param checks and rendering the ChatOnboarding
component once more if the user slot isn't present in the URL query params, we use a special component <MovexBoundResource />
which takes in the rid
and it's rendering the Chat UI, providing a boundResource
in the render function params.
What is the BoundResource
? π€¨
This is what makes the movex magic possible. It's the glue between the UI and the state changes both on the front-end and the back-end. It also offers an api similar to the useReducer
allowing us to read the state
and call dispatch(action)
on it.
And finally, start the movex service
npx movex dev
This runs the server based on the movex.config file at localhost:3333
.
Step 5. Let's check the results
Going to localhost:3000
and picking a slot now should take you to a URL similar to http://localhost:3000/chat/chat:f02e10c5-ccfb-47b5-a71e-55bb44c56953?slot=pink
.
Click the "Invite" button and open it in another tab to test the multiplayer mode too. You should see something like this:
You nailed it!
Find the full code here: https://github.com/GabrielCTroia/movex-next-chat
Also, if you want to try your hand at it, you can take it a step further and add "{user} is typing..." logic or display a list of active users. This will involve you creating and handling new actions.
What about deploying this somewhere so I can chat with my real friends?
It's pretty straightforward to run Movex on Docker and deploy that to Fly.io or AWS, and I'll write a tutorial on how to do that in the future, but for now you can check the documentation.
P.S. Can you help me out? β€οΈ
I hope you learned something useful in this tutorial and I'm really curious to see if it inspired you to build something cool.
Please leave a comment down below and/or star the Movex repo if you think the project is worthy to be known by others or to be developed further.
https://github.com/movesthatmatter/movex
Top comments (4)
I love it! Interesting approach using the determinstic action propagation, looks clean and easy to implement. I think this can be the future of boostrapping turn based games! Looking forward to the development!
Thank you! Yeah, it works with turn based games but also any other app that needs to share a global state with multiple users.
Thank you
Thank you! Did you find anything inspiring, worth sharing in the article?