Building an end-to-end chat app by only writing front-end code

gabrielctroia

Gabriel C. Troia

Posted on August 1, 2023

Building an end-to-end chat app by only writing front-end code

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.

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
Enter fullscreen mode Exit fullscreen mode

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;
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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,
        });
      }}
    />
  );
};

Enter fullscreen mode Exit fullscreen mode

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

//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 },
        });
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

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

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

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.

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

yarn add movex movex-react movex-core-util; yarn add --dev movex-service
Enter fullscreen mode Exit fullscreen mode

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,
  },
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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, 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

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

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

πŸ’– πŸ’ͺ πŸ™… 🚩
gabrielctroia
Gabriel C. Troia

Posted on August 1, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related