DEV Community

loading...
Cover image for Multiple Users using the same Form in Real Time. Nx, NestJs and Angular

Multiple Users using the same Form in Real Time. Nx, NestJs and Angular

Daniel Marin
Passionate about autonomous operations
・12 min read

Visual representation of the end product

If you don't have time to go through the tutorial, here's a final version of the code

In this article I wanted to explore something I've been asked to build several times for different use cases. With distributed and remote teams, real time cooperation is key for success. Whenever we hear about Real Time applications we always see the same example, a Chat. Although chats and cool and important, there's a simpler thing that can help teams maximize cooperation; Forms that can be edited by multiple users concurrently.

It seems challenging, and of course, depending on the use case it can be harder and more expensive. It can get expensive simply because it means more data being sent back and forward. If your application is running on a VPS or a dedicated server you may be able to do this without any extra expenses, but if you are doing serverless this means more money you'll spend at the end of the month.

In a traditional form implementation, every client has its own state and it sends a request only when the form is submitted. In this case, things are more complex, every time a client updates the form, all the other clients should receive this information. If you are planning to use this feature in apps with just a few users, its Okay, but if you are planning to have 1,000 users concurrently changing the form, you have to take into account that each change will send data to all the 1,000 users.

In this case I'm gonna focus on doing a very simple implementation to get you started, this is by no means a production ready application.

The Problem

Let's say you have multiple users that have to work together towards a goal, you want to reduce friction as much as possible. Having a mechanism to work on the same task together in real time can be really useful.

The Solution

There should be a service responsible for tracking the current state of the task and sending updates to all the connected clients. The Web Client that will be used by the clients, should display the connected clients and a form that can be changed by user interaction or by updates coming from the service.

Since there's a big chance of concurrency, we have to choose a strategy that helps us with that. I'm personally a fan of Redux, so I based my implementation on it but adjusted it according to my needs. Since this is a very small app, I used pure RxJs for my state management implementation. The actions that can occur are:

  • Init: It sets the initial state of the web client, its triggered when each client loads.
  • ClientConnected: Everytime a client connects to the service, all the clients receive an updated list of the currently connected clients.
  • Data: Whenever a client is connected, the service responds with the current form state.
  • PatchValue: When a client updates the form by directly interacting with it, it sends the changes to the service.
  • ValuePatched: When the service receives a change to the state, it broadcasts it to all the other clients.

For this sample the form data is very simple and it only consists of a title and description, both of type string.

Implementation

First thing is to choose the technologies we want to use. I'm a proud Angular Developer, so I choose to use Angular for the Web Client. Since NestJs is so cool, I decided to use it for the service responsible for synchronization. Finally since the Web Client and the service are going to be communicating in real time, Nx can be really helpful to reduce duplication and ensure the messages passing through are type safe using shared interfaces.

NOTE: For the Web Client you can use any JS framework or even plain Javascript. Same thing with the service, you can use Node or whatever you want as long as you have a Socket.IO implementation. I used Nx just because I like it but you can also skip that part.

We'll start by generating the Nx workspace.

  • Run the command npx create-nx-workspace@latest realtime-form
  • Choose angular-nest workspace in the prompt options
  • Type web-client as the Application name
  • Select your preferred stylesheet format (I always use SASS)
  • Go to the realtime-form directory

One of the cool things about using Nx with NestJs and Angular is the possibility to share things between them. Let's take advantage of it and create the FormData interface and ActionTypes enum.

Go to /libs/api-interfaces/src/lib/api-interfaces.ts and change its content to this:

export enum ActionTypes {
  Data = '[Socket] Data',
  ClientConnected = '[Socket] Client Connected',
  ValuePatched = '[Socket] Value Patched',
  PatchValue = '[Form] Patch Value',
  Init = '[Init] Init'
}

export interface FormData {
  title: string;
  description: string;
}
Enter fullscreen mode Exit fullscreen mode

Now we are able to use them from the service and the web client, since its shared it works as a contract between the two of them.

We're going to start with the service:

  • Run npm i --save @nestjs/websockets @nestjs/platform-socket.io
  • Run npm i --save-dev @types/socket.io
  • Go to the directory /apps/api/src/app
  • Create a new directory called events and move to that directory
  • Create a file named events.gateway.ts
  • Create a file named events.module.ts

And next you just have to write the new file's content.

Go to /apps/api/src/app/events/events.gateway.ts:

import {
  SubscribeMessage,
  WebSocketGateway,
  WebSocketServer
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { Logger } from '@nestjs/common';

import { ActionTypes, FormData } from '@realtime-form/api-interfaces';

@WebSocketGateway()
export class EventsGateway {
  connectedClients = [];
  data = {};
  @WebSocketServer()
  server: Server;
  private logger: Logger = new Logger('EventsGateway');

  handleConnection(client: Socket) {
    this.connectedClients = [...this.connectedClients, client.id];
    this.logger.log(
      `Client connected: ${client.id} - ${this.connectedClients.length} connected clients.`
    );
    this.server.emit(ActionTypes.ClientConnected, this.connectedClients);
    client.emit(ActionTypes.Data, this.data);
  }

  handleDisconnect(client: Socket) {
    this.connectedClients = this.connectedClients.filter(
      connectedClient => connectedClient !== client.id
    );
    this.logger.log(
      `Client disconnected: ${client.id} - ${this.connectedClients.length} connected clients.`
    );
    this.server.emit(ActionTypes.ClientConnected, this.connectedClients);
  }

  @SubscribeMessage(ActionTypes.PatchValue)
  patchValue(client: Socket, payload: Partial<FormData>) {
    this.data = { ...this.data, ...payload };
    this.logger.log(`Patch value: ${JSON.stringify(payload)}.`);
    client.broadcast.emit(ActionTypes.ValuePatched, payload);
  }
}
Enter fullscreen mode Exit fullscreen mode

If you are scratching your head with that code snippet, don't worry, we are trusting NestJs to do all the heavy lifting. You can think of each method as the response to an event; connection, disconnection and patch value.

  • Connection: Update the list of connected clients, log to the service the event occurred, emit the new connectedClients list to all the currently connected clients and emit to the client the current state of the form.
  • Disconnection: Update the list of connected clients, log to the service the event occurred, emit the new connectedClients list to all the currently connected clients.
  • PatchValue: Update the current state of the form, log to the service the event occurred, broadcast the new state to all the currently connected clients.

NOTE: The difference between this.server.emit and client.broadcast.emit, is that the first sends the message to all the clients while the second sends the message to all BUT the sender.

Now lets update the /apps/api/src/app/events/events.module.ts file:

import { Module } from '@nestjs/common';
import { EventsGateway } from './events.gateway';

@Module({
  providers: [EventsGateway]
})
export class EventsModule {}
Enter fullscreen mode Exit fullscreen mode

And the /apps/api/src/app/app.module.ts file:

import { Module } from '@nestjs/common';
import { EventsModule } from './events/events.module';

@Module({
  imports: [EventsModule]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

I also removed the AppController and AppService files. And also updated the apps/api/src/main.ts file with this:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const port = 3000;
  await app.listen(port, () => {
    console.log('Listening at http://localhost:' + port);
  });
}

bootstrap();
Enter fullscreen mode Exit fullscreen mode

Now it's time to get started with the web client, go to apps/web-client/src/app/app.component.html:

<header>
  <h1>Realtime Form</h1>
</header>

<main>
  <form [formGroup]="form">
    <fieldset>
      <label class="form-control">
        <span>Title: </span>
        <input formControlName="title" />
      </label>

      <label class="form-control">
        <span>Description: </span>
        <textarea formControlName="description" rows="5"></textarea>
      </label>
    </fieldset>
  </form>

  <ng-container *ngIf="connectedClients$ | async as clients">
    <h2>Clients ({{ clients.length }})</h2>
    <ul>
      <li *ngFor="let client of clients">{{ client }}</li>
    </ul>
  </ng-container>
</main>
Enter fullscreen mode Exit fullscreen mode

Just to make sure it looks just like what I showed at the beginning, Go to /apps/web-client/src/app/app.component.scss and replace its content with this:

form {
  width: 100%;
  padding: 0.5rem;
  max-width: 600px;

  .form-control {
    display: flex;
    margin-bottom: 1rem;

    & > span {
      flex-basis: 20%;
    }

    & > input,
    & > textarea {
      flex-grow: 1;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Install the Socket IO package for Angular by using the command npm install --save ngx-socket-io

Don't forget to inject ReactiveFormsModule and SocketIoModule in the AppModule of the Web Client. Go to /apps/web-client/src/app/app.module.ts:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';

import { AppComponent } from './app.component';

import { SocketIoModule, SocketIoConfig } from 'ngx-socket-io';

const config: SocketIoConfig = {
  url: 'http://192.168.1.2:3000',
  options: {}
};

@NgModule({
  declarations: [AppComponent],
  imports: [BrowserModule, ReactiveFormsModule, SocketIoModule.forRoot(config)],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Next go to apps/web-client/src/app/app.component.ts:

import { Component, OnInit } from '@angular/core';
import { BehaviorSubject, merge } from 'rxjs';
import { scan, map } from 'rxjs/operators';
import { FormBuilder } from '@angular/forms';
import { Socket } from 'ngx-socket-io';

import { ActionTypes, FormData } from '@realtime-form/api-interfaces';
import { State, reducer } from './core/state';
import {
  ClientConnected,
  Data,
  ValuePatched,
  Action,
  Init
} from './core/actions';
import {
  getPatchValueEffect,
  getValuePatchedEffect,
  getFormChangesEffect
} from './core/effects';

@Component({
  selector: 'realtime-form-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.scss']
})
export class AppComponent implements OnInit {
  // 1: Action dispatcher
  private dispatcher = new BehaviorSubject<Action>(new Init());
  actions$ = this.dispatcher.asObservable();
  // 2: State stream
  store$ = this.actions$.pipe(
    scan((state: State, action: Action) => reducer(state, action))
  );
  // 3: Define all the selectors
  connectedClients$ = this.store$.pipe(
    map((state: State) => state.connectedClients)
  );
  data$ = this.store$.pipe(map((state: State) => state.data));
  title$ = this.data$.pipe(map((state: Partial<FormData>) => state.title));
  description$ = this.data$.pipe(
    map((state: Partial<FormData>) => state.description)
  );

  // 4: Initialize the form
  form = this.fb.group({
    title: [''],
    description: ['']
  });

  constructor(private socket: Socket, private fb: FormBuilder) {}

  ngOnInit() {
    // 5: Connect to all the socket events
    this.socket.on(ActionTypes.ClientConnected, (payload: string[]) => {
      this.dispatcher.next(new ClientConnected(payload));
    });

    this.socket.on(ActionTypes.Data, (payload: Partial<FormData>) => {
      this.dispatcher.next(new Data(payload));
    });

    this.socket.on(ActionTypes.ValuePatched, (payload: Partial<FormData>) => {
      this.dispatcher.next(new ValuePatched(payload));
    });

    // 6: Subscribe to all the effects
    merge(
      getPatchValueEffect(this.socket, this.actions$),
      getValuePatchedEffect(this.form, this.actions$),
      getFormChangesEffect(this.form, this.dispatcher)
    ).subscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

Let's go through each of the things I just did right there:

1: Action dispatcher

I start by creating an action dispatcher and an observable from the stream of actions going through, I use RxJs BehaviorSubject with an initial action that looks like this:

// apps/web-client/src/app/core/actions/init.action.ts
import { ActionTypes } from '@realtime-form/api-interfaces';

export class Init {
  type = ActionTypes.Init;
  payload = null;
}
Enter fullscreen mode Exit fullscreen mode

I also created an Action type inside a barrel import to make it easier to use:

// apps/web-client/src/app/core/actions/index.ts
import { Init } from './init.action';

export type Action = Init;
export { Init };
Enter fullscreen mode Exit fullscreen mode

2: State stream

By using the scan operator we can take every emission of an observable, keep an internal state that gets updated by the return of its callback. With a reducer function that takes a state and action, and returns a state in an inmutable way we can have a stream of the current state in a safer way.

I created a reducer that looks like this:

// apps/web-client/src/app/core/state/state.reducer.ts
import { ActionTypes } from '@realtime-form/api-interfaces';
import { State } from './state.interface';
import { Action } from '../actions';
import { initialState } from './initial-state.const';

export const reducer = (state: State, action: Action): State => {
  switch (action.type) {
    case ActionTypes.Init:
      return { ...initialState };
    case ActionTypes.ClientConnected:
      return {
        ...state,
        connectedClients: action.payload
      };
    case ActionTypes.Data:
      return { ...state, data: action.payload };
    case ActionTypes.PatchValue:
      return { ...state, data: { ...state.data, ...action.payload } };
    default:
      return { ...state };
  }
};
Enter fullscreen mode Exit fullscreen mode

A brief description of the actions:

  • Init: Set the state to the initialState const.
  • ClientConnected: Update the connectedClients in the state with the updated list.
  • Data: Set the data of the state to the value returned upon connection.
  • PatchValue: Patch the data with the changes from the payload.

The State interface looks like this:

// apps/web-client/src/app/core/state/state.interface.ts
import { FormData } from '@realtime-form/api-interfaces';

export interface State {
  connectedClients: string[];
  data: Partial<FormData>;
}
Enter fullscreen mode Exit fullscreen mode

The initialState const looks like this:

// apps/web-client/src/app/core/state/initial-state.const.ts
import { State } from './state.interface';

export const initialState = {
  connectedClients: [],
  data: {}
} as State;
Enter fullscreen mode Exit fullscreen mode

I also created a barrel import here, I kinda love them.

export { initialState } from './initial-state.const';
export { State } from './state.interface';
export { reducer } from './state.reducer';
Enter fullscreen mode Exit fullscreen mode

3: Define all the selectors

In order to make it easy to access the values in the store, I created an extra set of observables that are basically mapping the state to sub states, it works like a projection.

4: Initialize the form

I just created a very VERY simple form using ReactiveForms, if you want to learn more about them you can take a look at my ReactiveForms series.

5: Connect to all the socket events

As we just saw, there are three events that can be emitted by our service, in this step we are listening to those events and responding accordingly. To make it cleaner I created some action creator classes.

// apps/web-client/src/app/core/actions/client-connected.action.ts
import { ActionTypes } from '@realtime-form/api-interfaces';

export class ClientConnected {
  type = ActionTypes.ClientConnected;

  constructor(public payload: string[]) {}
}
Enter fullscreen mode Exit fullscreen mode
// apps/web-client/src/app/core/actions/data.action.ts
import { ActionTypes, FormData } from '@realtime-form/api-interfaces';

export class Data {
  type = ActionTypes.Data;

  constructor(public payload: Partial<FormData>) {}
}
Enter fullscreen mode Exit fullscreen mode
// apps/web-client/src/app/core/actions/value-patched.action.ts
import { ActionTypes, FormData } from '@realtime-form/api-interfaces';

export class ValuePatched {
  type = ActionTypes.ValuePatched;

  constructor(public payload: Partial<FormData>) {}
}
Enter fullscreen mode Exit fullscreen mode

And do not forget to update the barrel import

// apps/web-client/src/app/core/actions/index.ts
import { Init } from './init.action';
import { Data } from './data.action';
import { ClientConnected } from './client-connected.action';
import { ValuePatched } from './value-patched.action';

export type Action = Init | Data | ClientConnected | ValuePatched;
export { Init, Data, ClientConnected, ValuePatched };
Enter fullscreen mode Exit fullscreen mode

6: Subscribe to all the effects

The only thing left are the side effects. Let's go through each:

When the user updates the form, the changes have to be broadcasted to all the other clients, for this we need to emit to the service. We can achieve that doing this:

// apps/web-client/src/app/core/effects/patch-value.effect.ts
import { Action } from '../actions';
import { Observable, asyncScheduler } from 'rxjs';
import { observeOn, filter, tap } from 'rxjs/operators';
import { ActionTypes } from '@realtime-form/api-interfaces';
import { Socket } from 'ngx-socket-io';

export const getPatchValueEffect = (
  socket: Socket,
  actions: Observable<Action>
) => {
  return actions.pipe(
    observeOn(asyncScheduler),
    filter(action => action.type === ActionTypes.PatchValue),
    tap(action => socket.emit(ActionTypes.PatchValue, action.payload))
  );
};
Enter fullscreen mode Exit fullscreen mode

NOTE: I use the asyncScheduler only because I want to ensure that the reducer is always first.

When the service emits that the value has changed or it sends the current form state upon connection, we have to respond accordingly. We are already mapping the socket event to an action in both cases, now we just need an effect that updates the form locally for each client.

// apps/web-client/src/app/core/effects/value-patched.effect.ts
import { Action } from '../actions';
import { Observable, asyncScheduler } from 'rxjs';
import { observeOn, filter, tap } from 'rxjs/operators';
import { ActionTypes } from '@realtime-form/api-interfaces';
import { FormGroup } from '@angular/forms';

export const getValuePatchedEffect = (
  form: FormGroup,
  actions: Observable<Action>
) => {
  return actions.pipe(
    observeOn(asyncScheduler),
    filter(
      action =>
        action.type === ActionTypes.ValuePatched ||
        action.type === ActionTypes.Data
    ),
    tap(action => form.patchValue(action.payload, { emitEvent: false }))
  );
};
Enter fullscreen mode Exit fullscreen mode

And finally, whenever a client interacts with the form we want to emit a message to the service that will propagate this change across all the connected clients.

// apps/web-client/src/app/core/effects/form-changes.effect.ts
import { Action, PatchValue } from '../actions';
import { merge, BehaviorSubject } from 'rxjs';
import { debounceTime, map, tap } from 'rxjs/operators';
import { FormGroup } from '@angular/forms';
import { FormData } from '@realtime-form/api-interfaces';

export const getFormChangesEffect = (
  form: FormGroup,
  dispatcher: BehaviorSubject<Action>
) => {
  const title$ = form
    .get('title')
    .valueChanges.pipe(map((title: string) => ({ title })));

  const description$ = form
    .get('description')
    .valueChanges.pipe(map((description: string) => ({ description })));

  return merge(title$, description$).pipe(
    debounceTime(300),
    tap((payload: Partial<FormData>) =>
      dispatcher.next(new PatchValue(payload))
    )
  );
};
Enter fullscreen mode Exit fullscreen mode

You probably noticed a new PatchValue action, so let's create it:

// apps/web-client/src/app/core/actions/patch-value.action.ts
import { ActionTypes, FormData } from '@realtime-form/api-interfaces';

export class PatchValue {
  type = ActionTypes.PatchValue;

  constructor(public payload: Partial<FormData>) {}
}
Enter fullscreen mode Exit fullscreen mode

And also update the barrel import:

// apps/web-client/src/app/core/actions/index.ts
import { Init } from './init.action';
import { Data } from './data.action';
import { ClientConnected } from './client-connected.action';
import { ValuePatched } from './value-patched.action';
import { PatchValue } from './patch-value.action';

export type Action = Init | Data | ClientConnected | ValuePatched | PatchValue;
export { Init, Data, ClientConnected, ValuePatched, PatchValue };
Enter fullscreen mode Exit fullscreen mode

Since I love barrel imports I created another one for the effects:

// apps/web-client/src/app/core/effects/index.ts
export { getFormChangesEffect } from './form-changes.effect';
export { getPatchValueEffect } from './patch-value.effect';
export { getValuePatchedEffect } from './value-patched.effect';
Enter fullscreen mode Exit fullscreen mode

Now you just have to run the services, each in a different terminal while in the main directory of the application:

  • Run the command ng serve
  • Run the command ng serve api

Conclusion

And that was it. The first time I had to do this was really challenging, so I tried to be as explicit as I could with each step, hoping you don't get lost. As I mentioned before this is not a production ready implementation but a really good point of start. Now that you know how to solve this problem, don't forget that sometimes the solution can be worse and in some cases this could increase infrastructure costs.

Icons made by itim2101 from Flaticon

Discussion (3)

Collapse
nikhilnandagopal profile image
Nikhil Nandagopal

Hey Daniel, thanks for the great post! Out of curiosity what are some of the use cases for multiple users using the same form in realtime?

Collapse
danmt profile image
Daniel Marin Author

Hey Nikhil, any time you want users to cooperate. Google Doc is basically a huge textarea input that multiple users can work with in real-time

Some comments have been hidden by the post's author - find out more