Building an end-to-end chat app by only writing front-end code
Tags: javascript, react, frontend, backend
Published: 2023-08-01 21:39:51

## TL;DR

I built a multi-user, end-to-end, [server-authoritative](https://www.gabrielgambetta.com/client-server-game-architecture.html#authoritative-servers-and-dumb-clients) chat app in react without writing a single line of back-end code.

![Chat App](https://user-images.githubusercontent.com/2099521/257645639-67b3bd01-d2fd-4d6b-91f4-bb6c43782435.gif "Chat App")

## 👩‍💻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:

```ts
//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](https://react.dev/reference/react/useReducer).

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](https://redux.js.org/) or [useReducer]((https://react.dev/reference/react/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.

```tsx
//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!😉

```tsx
//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.

```tsx
//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 the `props` rather than using `useReducer` directly? Make sure to read till the end of the article to find out.

## Step 3: Hook up the UI with the logic

```tsx
//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 👇

![Onboarding Screenshot](https://user-images.githubusercontent.com/2099521/256879215-b66e3cca-2238-4101-9057-84c3d2dcf573.png "Onboarding Screenshot")

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.

![Single Chat Box Window](https://user-images.githubusercontent.com/2099521/257643358-790e2c2f-fd29-4ac9-b986-fd0613916b38.gif "Single Chat Box Window")


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](https://movex.dev), which handles all of these out of the box as well as the state management on the front-end.

![Movex Logo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bsgt24urbnsrqlzownos.png "Movex Logo")

### 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

```bash
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](https://www.movex.dev/docs/how/resources) and enables it to run the back-end code without you having to do anything extra.

```ts
//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](https://www.movex.dev/docs/features/server_authoritative#determinstic-action-propagation-method) 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:

```ts
//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](https://react-redux.js.org/api/provider), 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:

```tsx
//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.

1. The `/pages/index` will be responsible for the Chat Onboarding
2. 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](https://www.movex.dev/docs/how/resources), 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.

```tsx
//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

```bash
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:

![Completed Chat App Demo](https://user-images.githubusercontent.com/2099521/257645639-67b3bd01-d2fd-4d6b-91f4-bb6c43782435.gif "Completed Chat App Demo")

## 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.

![alt text](https://media.giphy.com/media/2A75RyXVzzSI2bx4Gj/giphy.gif "Funny Gif")

## 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](https://www.movex.dev/docs/deploy).

## 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](https://github.com/movesthatmatter/movex) if you think the project is worthy to be known by others or to be developed further.
https://github.com/movesthatmatter/movex