DEV Community

Cover image for How to Build a Real-Time Dashboard with Encore.ts and React
Marcus Kohlberg for Encore

Posted on

How to Build a Real-Time Dashboard with Encore.ts and React

Real-time dashboards are incredibly useful in various applications, from tracking website analytics to monitoring live financial data or even keeping tabs on IoT devices.

💡 In this tutorial, we’ll show you how to build one using React and Encore.ts. You’ll learn to create a dynamic dashboard that streams updates instantly, empowering you to make quick, informed decisions.

To get a glimpse of what we’ll build, check out this GIF of the finished product and the source code here. Let’s dive in!

Image1

Prerequisites

Before we start, make sure you have these things installed on your computer

  • Node.js (v18 or later)
  • Npm (v10 or later)

What and Why Encore

Encore.ts is an open-source framework that helps you build backends with TypeScript, ensuring type safety. It's lightweight and fast because it doesn't have any NPM dependencies.

When developing distributed backend systems, it's often hard to replicate the production environment locally, leading to a poor developer experience. You may end up dealing with a lot of complexity just to get things running locally in a reasonable way, which takes time from focusing on building the actual application. Encore.ts addresses this by providing a complete toolset for building distributed systems, including:

  • The local environment matches the cloud
  • Cross-service type-safety
  • Type-aware infrastructure
  • Automatic API docs & clients
  • Local test tracing
  • And more

Alright, we talked about what Encore is and how it helps us build backend services. In the next section, let's install Encore locally and start building.

Installing Encore

To work with Encore, we need to install the CLI, which makes it very easy to create and manage local environments.

# macOS
brew install encoredev/tap/encore

# Windows
iwr https://encore.dev/install.ps1 | iex

# Linux
curl -L https://encore.dev/install.sh | bash
Enter fullscreen mode Exit fullscreen mode

Creating Encore application

Creating an Encore application is very easy, you just need to run the command.

encore app create
Enter fullscreen mode Exit fullscreen mode

You will be asked the following questions, so choose your answers accordingly.

Select language for your applicatio : TypeScript
Template: Empty app
App Name : real-time-dashboard
Enter fullscreen mode Exit fullscreen mode

Once the App is created then you can verify the application config in encore.app

{
    "id":   "real-time-dashboard-<random-id>",
    "lang": "typescript"
}
Enter fullscreen mode Exit fullscreen mode

All right, we have created the Encore application. Let's talk about Streaming APIs in Encore in the next section.

What are Streaming APIs in Encore

Before we talk about streaming APIs, let's discuss APIs in Encore. Creating an API endpoint in Encore is very easy because it provides the api function from the module encore.dev/api to define the API endpoint with type safety. Encore also has built-in validation for incoming requests. At their core, APIs are simple async functions with a request and response schema structure. Encore parses the code and generates the boilerplate at compile time, so you only need to focus on defining the APIs.

Streaming APIs are APIs that let you send and receive data to and from your application, allowing two-way communication.

Encore offers three types of streams, each for a different data flow direction:

  • StreamIn: Use this to stream data into your service.
  • StreamOut: Use this to stream data out from your service.
  • StreamInOut: Use this to stream data both into and out of your service.

When you connect to a streaming API endpoint, the client and server perform a handshake using an HTTP request. If the server accepts this request, a stream is created for both the client and the API handler. This stream is actually a WebSocket that allows sending and receiving messages.

Alright, now that we know what APIs and Streaming APIs are in Encore, let's create our dashboard service in the next section with Streaming API endpoints to store and retrieve data in real time.

Creating dashboard service

Let's create a dashboard service where we'll define our sales API to stream data to and from our sales dashboard.

Create a folder at the root level called dashboard and then add an encore.service.ts file. This file will tell Encore to treat the dashboard folder and its subfolders as part of the service.

# create dashboard folder
mkdir dashboard

# switch to dashboard folder
cd dashboard

# create encore.service.ts file inside dashboard folder
touch encore.service.ts
Enter fullscreen mode Exit fullscreen mode

Then add the following code to the encore.service.ts file. We import the Service class from encore.dev/service and create an instance of it by using "dashboard" as the service name.

import { Service } from 'encore.dev/service';

export default new Service('dashboard');
Enter fullscreen mode Exit fullscreen mode

Now let's create a dashboard.ts file and set up the sale API.

# make sure you are in dashboard folder
touch dashboard.ts
Enter fullscreen mode Exit fullscreen mode

Image2

Before setting up the API, we will first set up the database to store the sales data. We will use SQLDatabase from the module encore.dev/storage/sqldb to create a PostgreSQL database supported by Encore.

We need to define SQL as a migration, which Encore will pick up when we execute the command encore run.

Create a folder named migrations inside the dashboard folder, and then create a file called 1_first_migration.up.sql. Make sure to follow the naming convention, starting with number_ and ending with up.sql.

# 1_first_migration.up.sql

CREATE TABLE sales (
    id BIGSERIAL PRIMARY KEY,
    sale VARCHAR(255) NOT NULL,
    total INTEGER NOT NULL,
    date DATE NOT NULL
);

Enter fullscreen mode Exit fullscreen mode

Here, we are creating a table called sales with four columns:

  • id: auto-incremented and serves as the primary key
  • sale: title of the sale
  • total: total amount of the sale
  • date: date of the sale

Next, add the following code to the dashboard.ts file.

# dashboard.ts

import { SQLDatabase } from 'encore.dev/storage/sqldb';
import postgres from 'postgres';

const db = new SQLDatabase('dashboard', {
  migrations: './migrations',
});

const client = postgres(db.connectionString);
Enter fullscreen mode Exit fullscreen mode

Here, we create an instance of SQLDatabase by giving it the name dashboard and specifying the path to the migrations folder.

We are using the postgres package to listen for and notify changes in the database.

💡

Next, add these types and an in-memory map to hold the connected streams (websocket connections).

# dashboard.ts

...

// Map to hold all connected streams
const connectedStreams: Map<string, StreamOut<Sale>> = new Map();

interface HandshakeRequest {
  id: string;
}

interface Sale {
  sale: string;
  total: number;
  date: string;
}

interface ListResponse {
  sales: Sale[];
}

Enter fullscreen mode Exit fullscreen mode

Next, let's set up a sale streaming endpoint to send updates when a new sale happens.

# dashboard.ts
...

import { api, StreamOut } from 'encore.dev/api';
import log from 'encore.dev/log';

...

export const sale = api.streamOut<HandshakeRequest, Sale>(
  { expose: true, auth: false, path: '/sale' },
  async (handshake, stream) => {
    connectedStreams.set(handshake.id, stream);

    try {
      await client.listen('new_sale', async function (data) {
        const payload: Sale = JSON.parse(data ?? '');

        for (const [key, val] of connectedStreams) {
          try {
            // Send the users message to all connected clients.
            await val.send({ ...payload });
          } catch (err) {
            // If there is an error sending the message, remove the client from the map.
            connectedStreams.delete(key);
            log.error('error sending', err);
          }
        }
      });
    } catch (err) {
      // If there is an error reading from the stream, remove the client from the map.
      connectedStreams.delete(handshake.id);
      log.error('stream error', err);
    }
  }
);
Enter fullscreen mode Exit fullscreen mode

Here we use the api.streamOut function to define the API, which takes two arguments:

  • StreamOptions:
    • expose: Set to true to make the endpoint public, otherwise false
    • auth: Set to true to protect the endpoint with authentication, otherwise false
    • path: /sale
  • Function: It takes two arguments
    • handshake: Used to establish the stream connection
    • stream: The stream object

We keep connections in the connectedStreams map and listen to the new_sale channel using a Postgres client. When a new sale happens, we send updates to all connected streams.

Next, we will define the add sale API endpoint, where we get the sale data from the request body and use the db instance to insert the new sale record.

# dashboard.ts
...
...

export const addSale = api(
  { expose: true, method: 'POST', path: '/sale/add' },
  async (body: Sale & { id: string }): Promise<void> => {
    await db.exec`
      INSERT INTO sales (sale, total, date)
      VALUES (${body.sale}, ${body.total}, ${body.date})`;

    await client.notify(
      'new_sale',
      JSON.stringify({ sale: body.sale, total: body.total, date: body.date })
    );
  }
);

Enter fullscreen mode Exit fullscreen mode

Here, after adding the new sale record to the database, we use the Postgres client to send a notification to the new_sale channel with the sale data. This way, the new_sale channel listener gets notified and can take action.

Lastly, let's set up the API endpoint to return the list of sales records.

# dashboard.ts
...
export const listSales = api(
  { expose: true, method: 'GET', path: '/sale/list' },
  async (): Promise<ListResponse> => {
    const saleList = db.query`SELECT sale, total, date FROM sales`;

    const sales: Sale[] = [];

    for await (const row of saleList) {
      sales.push({ sale: row.sale, total: row.total, date: row.date });
    }

    return { sales };
  }
);
Enter fullscreen mode Exit fullscreen mode

Here, we use the db instance method query to get the data and then process it to return as a list.

Great, we now have all the API endpoints defined. Let's explore the Encore development dashboard in the next section.

Exploring the development dashboard

We have API endpoints with a database setup, but how do we test and debug the services? Don't worry, because Encore provides a Local Development dashboard to make developers' lives easier and boost productivity.

It includes several features to help you design, develop, and debug your application:

  • Service Catalog and API Explorer for easily making API calls to your local backend
  • Distributed Tracing for easy and powerful debugging
  • Automatic API Documentation for sharing knowledge and answering questions
  • Encore Flow for visualizing your microservices architecture

All these features update in real time as you change your application.

To access the dashboard, start your Encore application with encore run, and it opens automatically.

encore run
Enter fullscreen mode Exit fullscreen mode

This is how the dashboard looks, and you can test everything locally before going to production. This makes it much easier to test microservices architecture without needing external tools.

Image3

Here is an example of adding a new sale using the API explorer. When you click the Call API button, you will get a response and a log. On the right side, you can see the trace of the request.

Image4

When you click on the trace link, you get details like database queries, responses, and logs.

Image5

Alright, that's all about the local development dashboard. You can explore other options like the Service catalog, flow, and more. In the next section, we'll generate the client with TypeScript type safety to use in the Frontend service (React Application) to communicate with dashboard service APIs.

Generating the client

Encore can generate frontend request clients in TypeScript or JavaScript, keeping request/response types in sync and helping you call the APIs without manual effort.

Create a folder named frontend in the root directory and run the following command to set up the React project using Vite.

cd frontend 
npm create vite@latest . --  --template react-ts
Enter fullscreen mode Exit fullscreen mode

Next, create a lib folder inside the src directory, add a new file called client.ts, and leave it empty.

cd src
mkdir lib
cd lib 
touch client.ts
cd ../..
Enter fullscreen mode Exit fullscreen mode

Then, in the package.json file, add a new script called gen-client.

{
 "scripts":{
  ...
  "gen-client": "encore gen client <ENCORE-APP-ID> --output=./src/lib/client.ts --env=local"
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, run the script to create the client in src/lib/client.ts.

npm run gen-client
Enter fullscreen mode Exit fullscreen mode

The src/lib/client.ts file should contain the generated code.

...
/**
 * BaseURL is the base URL for calling the Encore application's API.
 */
export type BaseURL = string

export const Local: BaseURL = "http://localhost:4000"

/**
 * Environment returns a BaseURL for calling the cloud environment with the given name.
 */
export function Environment(name: string): BaseURL {
    return `https://${name}-real-time-dashboard-q892.encr.app`
}
...
Enter fullscreen mode Exit fullscreen mode

Next, create a file named getRequestClient.ts in the lib directory and add the following code. This will return the Client instance based on the environment.

import Client, { Local } from './client.ts';

const getRequestClient = () => {
  const isLocal =
    location.hostname === 'localhost' || location.hostname === '127.0.0.1';
  const env = isLocal ? Local : window.location.origin;

  return new Client(env);
};

export default getRequestClient;
Enter fullscreen mode Exit fullscreen mode

Alright, now we have the client to use in a React application to call the dashboard APIs. In the next section, let's create the frontend service and build the UI for the real-time sales dashboard.

Creating frontend service

In the previous section, we set up a frontend folder with a React application, and now we want to make it a service. Let's create an encore.service.ts file and add the following code to tell Encore to treat the frontend folder as a "frontend" service.

import { Service } from 'encore.dev/service';

export default new Service('frontend');
Enter fullscreen mode Exit fullscreen mode

We have two options:

  • Serve the dashboard and frontend services separately
  • Serve everything as a single bundle (we will use this approach for this tutorial)

To serve the React application, we need to build and serve it as static assets in Encore. Let's set up the static.ts file in the frontend folder.

Serving static files in Encore.ts is similar to regular API endpoints, but we use the api.static function instead.

import { api } from 'encore.dev/api';

export const frontend = api.static({
  expose: true,
  path: '/!path',
  dir: './dist',
});
Enter fullscreen mode Exit fullscreen mode

Here are two important things to note: we are passing the path and dir options.

  • path: /!path ensures it acts as a fallback route and doesn't conflict with other endpoints.
  • dir: ./dist is the directory of the build version of the React application.

Great, the static endpoint is set up. Now, let's install a few dependencies for our React application

  • react-router-dom
  • uuid
  • tailwind css
npm install react-router-dom uuid
Enter fullscreen mode Exit fullscreen mode

Then update the main.tsx with the code below.

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const router = createBrowserRouter([
  {
    path: '/',
    element: <App />,
  },
]);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

Next, let's set up Tailwind CSS and update a few files.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Enter fullscreen mode Exit fullscreen mode

Change the content section in tailwind.config.js

content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
Enter fullscreen mode Exit fullscreen mode

And index.css with the following code.

@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

Now let's create a few components for the sales dashboard.

  • SalesTable: to display the sales data in a table format.
# components/SalesTable.tsx

import { FC } from 'react';
import { dashboard } from '../lib/client';

interface SalesTableProps {
  salesData: dashboard.Sale[];
}

const SalesTable: FC<SalesTableProps> = ({ salesData }) => {
  return (
    <div className="overflow-x-auto">
      <table className="min-w-full bg-transparent">
        <thead>
          <tr>
            <th className="py-2 px-4 border-b text-left">Sale</th>
            <th className="py-2 px-4 border-b text-left">Total Sale</th>
            <th className="py-2 px-4 border-b text-left">Date of Sale</th>
          </tr>
        </thead>
        <tbody>
          {salesData.map((sale, index) => (
            <tr
              key={index}
              className="hover:bg-gray-100 transition-colors duration-200"
            >
              <td className="py-2 px-4 border-b">{sale.sale}</td>
              <td className="py-2 px-4 border-b">${sale.total}</td>
              <td className="py-2 px-4 border-b">{sale.date}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
};

export default SalesTable;
Enter fullscreen mode Exit fullscreen mode

Here, we are importing the types from the generated client to match the dashboard service type and ensure type safety.

  • SalesMetrics: to show some sales numbers like total, lowest, and average sale.
# components/SalesMetrics.tsx

import { FC, useMemo } from 'react';
import { dashboard } from '../lib/client';

interface SalesMetricsProps {
  recentSalesData: dashboard.Sale[];
  salesData: dashboard.Sale[];
}

const SalesMetrics: FC<SalesMetricsProps> = ({
  salesData,
  recentSalesData,
}) => {
  const {
    totalSalesAmount,
    distinctSalesCount,
    averageSaleAmount,
    highestSale,
    lowestSale,
  } = useMemo(() => {
    const combinedSales = [...salesData, ...recentSalesData];
    const hasRecentSalesData = combinedSales.length > 0;
    const totalSalesAmount = combinedSales.reduce(
      (acc, sale) => acc + sale.total,
      0
    );
    const distinctSalesCount = new Set(combinedSales.map((sale) => sale.sale))
      .size;
    const averageSaleAmount = hasRecentSalesData
      ? (totalSalesAmount / combinedSales.length).toFixed(2)
      : 0;
    const highestSale = hasRecentSalesData
      ? Math.max(...combinedSales.map((sale) => sale.total))
      : 0;
    const lowestSale = hasRecentSalesData
      ? Math.min(...combinedSales.map((sale) => sale.total))
      : 0;

    return {
      totalSalesAmount,
      distinctSalesCount,
      averageSaleAmount,
      highestSale,
      lowestSale,
    };
  }, [salesData, recentSalesData]);

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
      <div className="p-4 bg-blue-100 rounded-lg shadow-md">
        <h2 className="text-xl font-semibold text-blue-800">Total Sales</h2>
        <p className="text-2xl font-bold text-blue-900">
          ${totalSalesAmount.toLocaleString()}
        </p>
      </div>
      <div className="p-4 bg-blue-100 rounded-lg shadow-md">
        <h2 className="text-xl font-semibold text-blue-800">
          Total Number of Distinct Sales
        </h2>
        <p className="text-2xl font-bold text-blue-900">{distinctSalesCount}</p>
      </div>
      <div className="p-4 bg-blue-100 rounded-lg shadow-md">
        <h2 className="text-xl font-semibold text-blue-800">
          Average Sale Amount
        </h2>
        <p className="text-2xl font-bold text-blue-900">${averageSaleAmount}</p>
      </div>
      <div className="p-4 bg-blue-100 rounded-lg shadow-md">
        <h2 className="text-xl font-semibold text-blue-800">Highest Sale</h2>
        <p className="text-2xl font-bold text-blue-900">
          ${highestSale.toLocaleString()}
        </p>
      </div>
      <div className="p-4 bg-blue-100 rounded-lg shadow-md">
        <h2 className="text-xl font-semibold text-blue-800">Lowest Sale</h2>
        <p className="text-2xl font-bold text-blue-900">
          ${lowestSale.toLocaleString()}
        </p>
      </div>
    </div>
  );
};

export default SalesMetrics;
Enter fullscreen mode Exit fullscreen mode
  • RoleSelector: To let users choose a role for the dashboard, we will show two options:
    • Viewer: Can view the sales dashboard
    • Manager: Can view and create new sales
# components/RoleSelector.tsx

import { useNavigate } from 'react-router-dom';

export type RoleType = 'viewer' | 'manager';

export const RoleSelector = () => {
  const navigate = useNavigate();
  const handleRoleChange = (role: RoleType) => {
    const query = new URLSearchParams({ role });
    navigate(`/?${query}`);
  };

  return (
    <div className="h-screen grid place-content-center">
      <div>
        <h1 className="text-4xl text-center">
          Welcome to the Sales Dashboard! Please select your role
        </h1>
        <div className="flex justify-center mt-8">
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-4"
            onClick={() => handleRoleChange('viewer')}
          >
            Viewer
          </button>
          <button
            className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
            onClick={() => handleRoleChange('manager')}
          >
            Manager
          </button>
        </div>
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode
  • GenerateSales: to display the generate sale button and contain the logic for generating sales.

To generate sales, we'll need some mock data, so let's create a src/constant.ts file and add the mock data

# src/constant.ts

import { dashboard } from './lib/client';

export const MOCK_SALES_DATA: dashboard.Sale[] = [
  {
    sale: 'Laptop',
    total: 1200,
    date: '2024-01-01',
  },
  {
    sale: 'Smartphone',
    total: 800,
    date: '2024-01-02',
  },
  {
    sale: 'Headphones',
    total: 150,
    date: '2024-01-03',
  },
  {
    sale: 'Monitor',
    total: 300,
    date: '2024-01-04',
  },
  {
    sale: 'Keyboard',
    total: 100,
    date: '2024-01-05',
  },
  {
    sale: 'Mouse',
    total: 50,
    date: '2024-01-06',
  },
  {
    sale: 'Tablet',
    total: 400,
    date: '2024-01-07',
  },
  {
    sale: 'Smartwatch',
    total: 200,
    date: '2024-01-08',
  },
  {
    sale: 'Camera',
    total: 600,
    date: '2024-01-09',
  },
  {
    sale: 'Printer',
    total: 150,
    date: '2024-01-10',
  },
];
Enter fullscreen mode Exit fullscreen mode
# components/GenerateSales.tsx

import { v4 as uuidv4 } from 'uuid';
import { MOCK_SALES_DATA } from '../constant';
import getRequestClient from '../lib/getRequestClient';

const GenerateSales = () => {
  const handleGenerateSales = async () => {
    const uniqueID = uuidv4();
    const sendSalesData = async () => {
      for await (const sale of MOCK_SALES_DATA) {
        // Simulate a delay of 2 seconds between each sale
        await new Promise((resolve) => setTimeout(resolve, 2000));
        await getRequestClient().dashboard.addSale({
          ...sale,
          id: uniqueID,
          sale: `${sale.sale} - ${new Date().toISOString()}`,
          date: new Date().toISOString().split('T')[0], // Format date as yyyy-mm-dd
        });
      }
    };

    await sendSalesData();
  };

  return (
    <div className="flex gap-4 mb-8">
      <button
        onClick={handleGenerateSales}
        className="bg-blue-500 text-white p-2 rounded hover:bg-blue-600 transition-colors duration-300"
      >
        Generate Sales
      </button>
    </div>
  );
};

export default GenerateSales;
Enter fullscreen mode Exit fullscreen mode

Here, we import the getRequestClient and then call the addSale endpoint from the dashboard service. It's very simple, and addSale is type-safe, so if you try to pass any attributes that aren't allowed, you'll get an error.

Next, let's create a SalesDashboard component to show the dashboard view with sales metrics, recent sales, and all-time sales.

# components/SalesDashboard.tsx

import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { dashboard } from '../lib/client';
import { v4 as uuidv4 } from 'uuid';
import getRequestClient from '../lib/getRequestClient';
import { RoleType } from './RoleSelector';
import SalesTable from './SalesTable';
import SalesMetrics from './SalesMetrics';
import GenerateSales from './GenerateSales';

interface SalesDashboardProps {
  role: RoleType;
}

export const SalesDashboard: FC<SalesDashboardProps> = ({ role }) => {
  const saleStream =
    useRef<
      Awaited<ReturnType<typeof dashboard.ServiceClient.prototype.sale>>
    >();

  const [userID] = useState<string>(uuidv4());
  const [loading, setLoading] = useState(true);
  const [recentSalesData, setRecentSalesData] = useState<dashboard.Sale[]>([]);
  const [salesData, setSalesData] = useState<dashboard.Sale[]>([]);

  const { isManager, roleLabel } = useMemo(() => {
    const isManager = role === 'manager';

    const roleLabel = isManager ? 'Manager' : 'Viewer';

    return { isManager, roleLabel };
  }, [role]);

  const { recentSales, combinedSales } = useMemo(() => {
    const combinedSales = [...salesData, ...recentSalesData];

    const recentSales = combinedSales.slice(-5).reverse(); // Show the last 5 sales

    return {
      recentSales,
      combinedSales,
    };
  }, [salesData, recentSalesData]);

  const fetchSales = async () => {
    try {
      const { sales } = await getRequestClient().dashboard.listSales();
      setSalesData(sales);
    } catch (error) {
      console.error('Error fetching sales data:', error);
    }
  };

  useEffect(() => {
    const connect = async () => {
      setLoading(true);
      saleStream.current = await getRequestClient().dashboard.sale({
        id: userID,
      });
      saleStream.current.socket.on('close', connect);
      saleStream.current.socket.on('open', () => setLoading(false));

      for await (const sale of saleStream.current) {
        setRecentSalesData((prevState) => {
          const mergedArray = prevState.concat([sale]);
          const updatedState = Array.from(
            new Map(mergedArray.map((item) => [item.sale, item])).values()
          );

          return updatedState;
        });
      }
    };

    connect();
    return () => {
      saleStream.current?.socket.off('close', connect);
      saleStream.current?.close();
    };
  }, [userID]);

  useEffect(() => {
    fetchSales();
  }, []);

  return (
    <div className="container mx-auto p-4">
      <div className="bg-gradient-to-r from-blue-500 to-purple-500 text-white p-6 rounded-lg shadow-md mb-8">
        <h1 className="text-3xl font-extrabold">
          Welcome to the Live Dashboard, {roleLabel}!
        </h1>
        <p className="mt-2 text-lg">
          Here you can monitor and manage all your sales data in real-time.
        </p>
      </div>
      {loading ? (
        <div className="text-center">Loading...</div>
      ) : (
        <>
          {isManager && <GenerateSales />}

          <SalesMetrics
            salesData={salesData}
            recentSalesData={recentSalesData}
          />

          <div className="mb-8 p-4 bg-blue-100 rounded-lg shadow-md">
            <h2 className="text-xl font-semibold mb-4">Recent Sales</h2>
            <SalesTable salesData={recentSales} />
          </div>

          <div className="mb-8 p-4 bg-blue-100 rounded-lg shadow-md">
            <h2 className="text-xl font-semibold mb-4">All Time Sales</h2>
            <SalesTable salesData={combinedSales} />
          </div>
        </>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

SalesDashboard takes one prop called role, which determines if it will show the GenerateSales component.

saleStream will hold the active stream reference and is strongly typed.

const saleStream =
    useRef<
      Awaited<ReturnType<typeof dashboard.ServiceClient.prototype.sale>>
    >();
Enter fullscreen mode Exit fullscreen mode

When the component mounts, we create the stream connection using the sale endpoint of the dashboard service. We then listen for the socket open and close events and run the appropriate logic based on these events.

We read the sale data from saleStream.current and store it in the recentSalesData state.

When the component unmounts, we clean up and close the current stream.

useEffect(() => {
    const connect = async () => {
      setLoading(true);
      saleStream.current = await getRequestClient().dashboard.sale({
        id: userID,
      });
      saleStream.current.socket.on('close', connect);
      saleStream.current.socket.on('open', () => setLoading(false));

      for await (const sale of saleStream.current) {
        setRecentSalesData((prevState) => {
          return [...prevState, sale];
        });
      }
    };

    connect();
    return () => {
      saleStream.current?.socket.off('close', connect);
      saleStream.current?.close();
    };
  }, [userID]);
Enter fullscreen mode Exit fullscreen mode

This code gets the stored sales using the listSales endpoint from the dashboard service and saves them in the salesData state.

const fetchSales = async () => {
    try {
      const { sales } = await getRequestClient().dashboard.listSales();
      setSalesData(sales);
    } catch (error) {
      console.error('Error fetching sales data:', error);
    }
  };

   useEffect(() => {
    fetchSales();
  }, []);
Enter fullscreen mode Exit fullscreen mode

This code calculates the recent sales and all-time sales data.

 const { recentSales, combinedSales } = useMemo(() => {
    const combinedSales = [...salesData, ...recentSalesData];

    const recentSales = combinedSales.slice(-5).reverse(); // Show the last 5 sales

    return {
      recentSales,
      combinedSales,
    };
  }, [salesData, recentSalesData]);
Enter fullscreen mode Exit fullscreen mode

Finally, update the App.tsx file with this code.

# App.tsx

import { SalesDashboard } from './components/SalesDashboard';
import { RoleSelector, RoleType } from './components/RoleSelector';
import { useSearchParams } from 'react-router-dom';

function App() {
  const [queryParams] = useSearchParams();
  const role = queryParams.get('role') as RoleType;

  return (
    <main className="container mx-auto mt-8">
      {role ? <SalesDashboard role={role} /> : <RoleSelector />}
    </main>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Here, we are showing the SalesDashboard and RoleSelector components based on whether the role query parameter is available or not.

Now, let's build the React application by running the command below in the frontend root.

npm run build
Enter fullscreen mode Exit fullscreen mode

Once you run the command successfully, the dist folder will be created inside the frontend directory.

Great, now in the next section, let's run the application and test it from start to finish.

Running the application

Running the encore application is easy; just use the command below.

encore run 
Enter fullscreen mode Exit fullscreen mode

Once you run the command successfully, you will see logs in the terminal like this:

  ✔ Building Encore application graph... Done!
  ✔ Analyzing service topology... Done!
  ✔ Creating PostgreSQL database cluster... Done!
  ✔ Running database migrations... Done!
  ✔ Starting Encore application... Done!

  Encore development server running!

  Your API is running at:     http://127.0.0.1:4000
  Development Dashboard URL:  http://127.0.0.1:9400/<ENCORE-APP-ID> 
Enter fullscreen mode Exit fullscreen mode

Visit http://127.0.0.1:4000 in your browser, and you will see a screen like the one below.

Image6

Next, choose Viewer in one tab and Manager in another tab.

  • Viewer

Image7

  • Manager

Image8

While checking the development dashboard, we created a sale record, and it was saved in the database, so it's also visible in the UI.

Now, from the manager view, click on the Generate Sales button and watch as both tabs on the dashboard update in real-time.

Image1

Summary

In this tutorial, we created a real-time sales dashboard using React and Encore.ts. The app updates instantly with new sales and inventory items, helping with quick decisions. We used Encore.ts, an open-source framework, to build the backend with TypeScript for safe and smooth coding. Key features of Encore are:

  1. Type Safety: Makes sure all API endpoints and data structures are safe, reducing mistakes and making code more reliable.
  2. Streaming APIs: Allows real-time data streaming with StreamIn, StreamOut, and StreamInOut, enabling two-way communication between the client and server.
  3. Local Development Dashboard: Offers tools for testing and debugging, like a Service Catalog, API Explorer, and Distributed Tracing, boosting productivity.
  4. Automatic Client Generation: Creates frontend request clients in TypeScript or JavaScript, keeping request/response types aligned.
  5. Simplified Microservices: Lets you build apps with multiple services without the usual complexity, providing an easier way to handle microservices.

These features together make it easier to build and manage complex apps, offering a great developer experience.

Relevant links

Top comments (3)

Collapse
 
astrodevil profile image
Astrodevil

great tutorial

Collapse
 
marcuskohlberg profile image
Marcus Kohlberg

Thanks, kudos to Sachin

Collapse
 
astrodevil profile image
Astrodevil

He is awesome 🙌💯