Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET / ASP.NET-Core

Creating Real Time App with ASP.NET Core SignalR and React in Typescript

4.86/5 (11 votes)
7 Apr 2020CPOL6 min read 28K   825  
Scrum poker app using ASP.NET Core SignalR 3.1 with React in Typescript
SignalR now comes included with ASP.NET Core framework and they have made tons of improvement to make it lightweight and easier to use. To my surprise, I couldn’t find any good tutorial on how to use SignalR, and use it to make something interesting other than the same old chat app. I thought of creating something with SignalR other than the same boring chat app.

Introduction

In this tutorial, I will be guiding you through the main steps that are required to create a real-time app. I will not write the complete code here. You can find the complete source code on github.

The ScrumPoker App

In this tutorial, we will be creating an interesting app called ScrumPoker. We live in an Agile world so it’s very common that we do story estimation and pointing in our development or every sprint cycle. Back in the days, we used to have planning poker cards and team used to do story estimation via these cards but now everything is online, and we work remotely very often.

A user can create ScrumBoard and share the link with fellow teammates. Team members can enter there and start pointing out the stories. Points given by the team will be shown on the dashboard only when user who created ScrumBoard allows them to see.

Users get added on the dashboard in real-time and points submitted by them also get reflected in real-time.

Image 1

Image 2

Image 3

Image 4

Source Code

├───clientapp
├───Contracts
├───Controllers
├───Infrastructure
│ ├───NotificationHub
│ └───Persistence

You can download the complete source code from my github. Download it, clone it, fork it from https://github.com/vikas0sharma/ScrumPoker.

Dev Tools

We will be using ASP.NET Core 3.1, React 16.3+, Bootstrap 4, Node 10.13+, create-react-app, Redis, Visual Studio 2019, Visual Studio Code, Yarn package manager.

Here, I am assuming that you are familiar with ASP.NET Core environment and React. I would be guiding you with the special things you need to do to make SignalR work with React.

If you are new to SignalR, I would suggest you have a look into the official documents of Microsoft.

And if you like React, then definitely setting up the React development environment would be easy for you.

Basic Steps

  • Firstly, you need to create ASP.NET Core Web API project. Here, you will be creating a controller to handle requests from React app.
  • For our persistence, we will be using Redis. Why Redis? Because I wanted to keep my app simple and besides that, it's an app that needs its data to be persisted only when the app is running.
  • In ASP.NET Core project folder, you need to a create a separate folder for client app where all our React app code will reside.
  • I am using Yarn as package manager. It's your choice if you like NPM for your development.
  • I believe you are already familiar with create-react-app. It does all the heavy lifting for us and creates a basic app structure. Point to note here is that we will be writing our React app in Typescript. Why Typescript? Because it makes the developer’s life easy by catching silly bugs at development time.
    yarn create react-app my-app --template typescript
  • You can use the package.json file from my source code that will help you in setting up all the required packages.

Backend Code

Let’s first setup our server side code. In our app, we are going to have only two models, i.e., ScrumBoard and User.

Create Hub

SignalR via Hubs communicates between clients and servers. It’s the central location where we keep our communication logic. Here, we specify which clients will be notified.

JavaScript
using Microsoft.AspNetCore.SignalR;
using System;
using System.Threading.Tasks;

namespace API.Infrastructure.NotificationHub
{
    public class ScrumBoardHub : Hub
    {
        public async override Task OnConnectedAsync()
        {
            await base.OnConnectedAsync();
            await Clients.Caller.SendAsync("Message", "Connected successfully!");
        }

        public async Task SubscribeToBoard(Guid boardId)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, boardId.ToString());
            await Clients.Caller.SendAsync("Message", "Added to board successfully!");
        }
    }
}

As you can see, we have inherited from SignalR Hub class. On successful connection with client, OnConnectedAsync will be called. Whenever a client connects to the hub, a message will be pushed to the client.

We have exposed a method named ‘SubscribeToBoard’ that a client can call to subscribe to a scumboard by providing the scumboard id. If you notice, we have used ‘Groups’ property of Hub to create a group of clients for a particular board. We will create a group by board id and add all the clients who have requested updates for that board.

On Dashboard, a user can see who else has joined on the board and what they are doing on dashboard in real time.

Register Hub in Startup

In ConfigureServices methods of Startup, add AddSignalR.

JavaScript
services.AddSignalR();

In Configure method, register your Hub class.

JavaScript
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllers();
    endpoints.MapHub<ScrumBoardHub>("/scrumboardhub");// Register Hub class
});

Create Persistence

Like I said earlier, I am using Redis server to store temporary data/activities performed by user. Let’s create a class to perform CRUD operation using Redis. We will use StackExchange nuget package.

XML
<PackageReference Include="StackExchange.Redis" Version="2.1.28" />

Setup Redis connection in Startup class.

JavaScript
services.Configure<APISettings>(Configuration);

services.AddSingleton<ConnectionMultiplexer>(sp =>
{
     var settings = sp.GetRequiredService<IOptions<APISettings>>().Value;
     var configuration = ConfigurationOptions.Parse(settings.ConnectionString, true);
     
     configuration.ResolveDns = true;

     return ConnectionMultiplexer.Connect(configuration);
});

Repository class:

C#
using API.Contracts;
using StackExchange.Redis;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;

namespace API.Infrastructure.Persistence
{
    public class ScrumRepository : IScrumRepository
    {
        private readonly IDatabase database;

        public ScrumRepository(ConnectionMultiplexer redis)
        {
            database = redis.GetDatabase();
        }

        public async Task<bool> AddBoard(ScrumBoard scrumBoard)
        {
            var isDone = await database.StringSetAsync
                         (scrumBoard.Id.ToString(), JsonSerializer.Serialize(scrumBoard));

            return isDone;
        }

        public async Task<bool> AddUserToBoard(Guid boardId, User user)
        {
            var data = await database.StringGetAsync(boardId.ToString());

            if (data.IsNullOrEmpty)
            {
                return false;
            }

            var board = JsonSerializer.Deserialize<ScrumBoard>(data);
            board.Users.Add(user);

            return await AddBoard(board);
        }

        public async Task<bool> ClearUsersPoint(Guid boardId)
        {
            var data = await database.StringGetAsync(boardId.ToString());

            if (data.IsNullOrEmpty)
            {
                return false;
            }

            var board = JsonSerializer.Deserialize<ScrumBoard>(data);
            board.Users.ForEach(u => u.Point = 0);

            return await AddBoard(board);
        }

        public async Task<List<User>> GetUsersFromBoard(Guid boardId)
        {
            var data = await database.StringGetAsync(boardId.ToString());

            if (data.IsNullOrEmpty)
            {
                return new List<User>();
            }

            var board = JsonSerializer.Deserialize<ScrumBoard>(data);

            return board.Users;
        }

        public async Task<bool> UpdateUserPoint(Guid boardId, Guid userId, int point)
        {
            var data = await database.StringGetAsync(boardId.ToString());
            var board = JsonSerializer.Deserialize<ScrumBoard>(data);
            var user = board.Users.FirstOrDefault(u => u.Id == userId);
            if (user != null)
            {
                user.Point = point;
            }

            return await AddBoard(board);
        }
    }
}

A user can create a ScrumBoard where others user can create their profile and start voting or estimation of stories on the dashboard.

Let’s Expose Some Endpoints for Client App

We will create a controller class and expose some REST API that our React client app will use to send its request.

C#
using API.Contracts;
using API.Infrastructure.NotificationHub;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace API.Controllers
{
    [Route("scrum-poker")]
    [ApiController]
    public class ScrumPokerController : ControllerBase
    {
        private readonly IScrumRepository scrumRepository;
        private readonly IHubContext<ScrumBoardHub> hub;

        public ScrumPokerController(IScrumRepository scrumRepository, 
                                    IHubContext<ScrumBoardHub> hub)
        {
            this.scrumRepository = scrumRepository;
            this.hub = hub;
        }

        [HttpPost("boards")]
        public async Task<IActionResult> Post([FromBody] ScrumBoard scrumBoard)
        {
            var boardId = Guid.NewGuid();
            scrumBoard.Id = boardId;

            var isCreated = await scrumRepository.AddBoard(scrumBoard);
            if (isCreated)
            {
                return Ok(boardId);
            }

            return NotFound();
        }

        [HttpPost("boards/{boardId}")]
        public async Task<IActionResult> UpdateUsersPoint(Guid boardId)
        {
            var isAdded = await scrumRepository.ClearUsersPoint(boardId);
            await hub.Clients.Group(boardId.ToString())
                .SendAsync("UsersAdded", await scrumRepository.GetUsersFromBoard(boardId));
            if (isAdded)
            {
                return Ok(isAdded);
            }
            return NotFound();
        }

        [HttpPost("boards/{boardId}/users")]
        public async Task<IActionResult> AddUser(Guid boardId, User user)
        {
            user.Id = Guid.NewGuid();
            var isAdded = await scrumRepository.AddUserToBoard(boardId, user);
            await hub.Clients.Group(boardId.ToString())
                .SendAsync("UsersAdded", await scrumRepository.GetUsersFromBoard(boardId));
            if (isAdded)
            {
                return Ok(user.Id);
            }
            return NotFound();
        }

        [HttpGet("boards/{boardId}/users")]
        public async Task<IActionResult> GetUsers(Guid boardId)
        {
            var users = await scrumRepository.GetUsersFromBoard(boardId);

            return Ok(users);
        }

        [HttpGet("boards/{boardId}/users/{userId}")]
        public async Task<IActionResult> GetUser(Guid boardId, Guid userId)
        {
            var users = await scrumRepository.GetUsersFromBoard(boardId);
            var user = users.FirstOrDefault(u => u.Id == userId);
            return Ok(user);
        }

        [HttpPut("boards/{boardId}/users")]
        public async Task<IActionResult> UpdateUser(Guid boardId, User user)
        {
            var isUpdated = 
                await scrumRepository.UpdateUserPoint(boardId, user.Id, user.Point);
            await hub.Clients.Group(boardId.ToString())
                .SendAsync("UsersAdded", await scrumRepository.GetUsersFromBoard(boardId));

            return Ok(isUpdated);
        }
    }
}

If you notice, our controller is asking for IHubContext<ScrumBoardHub> in its constructor via dependency injection. This context class will be used to notify all the connected clients of a Group whenever a user is added to the board or whenever a user submits his/her point or whenever admin clear outs points submitted by all users. SendAsync method sends the notification as well the updated list of users to the clients. Here, message ‘UsersAdded’ may be misleading but it could be anything that you like, just keep in mind React app to use this message to perform some action so make sure to keep in sync with React app.

Enable Cors

Request to start the SignalR connection gets blocked by CORS policy so we need to configure our ASP.NET to allow requests from React app as they will be hosted in different origins.

ConfigureServices method:

JavaScript
services.AddCors(options =>
                options.AddPolicy("CorsPolicy",
                    builder =>
                        builder.AllowAnyMethod()
                        .AllowAnyHeader()
                        .WithOrigins("http://localhost:3000")
                        .AllowCredentials()));

Configure method:

JavaScript
app.UseCors("CorsPolicy");

Frontend Code

We will create separate components for board creation, user profile creation, dashboard, user list, header, navigation, etc. But the important point here is that we will keep our SignalR client logic in UserList components because user’s list needs to be refreshed whenever some other user performs some activity.

Let’s write our SignalR connection code but before that, we need to add SignalR package in our React app.

yarn add @microsoft/signalr

UserList component:

JavaScript
import React, { useState, useEffect, FC } from 'react';
import { User } from './user/User';
import { UserModel } from '../../models/user-model';
import { useParams } from 'react-router-dom';
import {
  HubConnectionBuilder,
  HubConnectionState,
  HubConnection,
} from '@microsoft/signalr';
import { getBoardUsers } from '../../api/scrum-poker-api';

export const UserList: FC<{ state: boolean }> = ({ state }) => {
  const [users, setUsers] = useState<UserModel[]>([]);
  const { id } = useParams();
  const boardId = id as string;
  useEffect(() => {
    if (users.length === 0) {
      getUsers();
    }
    setUpSignalRConnection(boardId).then((con) => {
      //connection = con;
    });
  }, []);

  const getUsers = async () => {
    const users = await getBoardUsers(boardId);
    setUsers(users);
  };

  const setUpSignalRConnection = async (boardId: string) => {
    const connection = new HubConnectionBuilder()
      .withUrl('https://localhost:5001/scrumboardhub')
      .withAutomaticReconnect()
      .build();

    connection.on('Message', (message: string) => {
      console.log('Message', message);
    });
    connection.on('UsersAdded', (users: UserModel[]) => {
      setUsers(users);
    });

    try {
      await connection.start();
    } catch (err) {
      console.log(err);
    }

    if (connection.state === HubConnectionState.Connected) {
      connection.invoke('SubscribeToBoard', boardId).catch((err: Error) => {
        return console.error(err.toString());
      });
    }

    return connection;
  };
  return (
    <div className="container">
      {users.map((u) => (
        <User key={u.id} data={u} hiddenState={state}></User>
      ))}
    </div>
  );
};

We have created setUpSignalRConnection method which creates a connection using HubConnectionBuilder. It also listens to the ‘UserAdded’ message from the server and decides what to do what message+payload arrives from the server. It basically refreshes the user list with updated data sent by the server.

In our React app, we have different components but they are pretty simple to understand, that is why I am not mentioning them here.

Conclusion

It's very easy to setup SignalR with React and give our app the real time power. I have just mentioned the important steps required to setup SignalR. You can go through the complete source code to understand complete bits and pieces working together. Surely, there are improvements that we can make in our app like we can use Redux for communication between components.

History

  • 7th April, 2020: Article published

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)