Skip to content

React gRPC-Web — Building Real-Time React Apps with gRPC Backends

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about React gRPC. We cover key concepts, practical examples, and best practices to help you master this topic.

React gRPC-Web combines React's component model with gRPC-Web's real-time streaming capabilities, enabling responsive dashboards and live-updating UIs backed by efficient protobuf-based APIs.

What You'll Learn

  • Generating TypeScript gRPC-Web clients from protobuf
  • React hooks for gRPC unary calls
  • React hooks for server-streaming
  • State management with gRPC data
  • Error handling and reconnection in React
  • Performance optimization for gRPC-Web in React

Why It Matters

React applications often use REST or Graphql, which add overhead for simple CRUD. gRPC-Web provides strongly-typed, efficient APIs with built-in streaming support. DodaTech's Durga Antivirus Pro uses React with gRPC-Web for its admin dashboard, reducing payload size by 60% compared to JSON REST APIs.

Real-World Use

A React dashboard shows real-time threat alerts via a gRPC-Web server-streaming call. When a new threat is detected, the server pushes it to all connected dashboards within 200ms. The React component updates the threat list optimistically without any polling.

sequenceDiagram
    participant React
    participant Envoy
    participant gRPC
    React->>Envoy: gRPC-Web StreamThreats
    Envoy->>gRPC: StreamThreats (HTTP/2)
    gRPC-->>Envoy: Threat detected
    Envoy-->>React: Threat data
    React->>React: setState (new threat)
    gRPC-->>Envoy: Threat detected
    Envoy-->>React: Threat data
    React->>React: setState (new threat)

Code Examples

Example 1: React Hook for gRPC Unary Call

import { useState, useEffect } from "react";
import { ThreatServiceClient } from "./proto/threat.client";
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";

const transport = new GrpcWebFetchTransport({
  baseUrl: process.env.REACT_APP_API_URL || "/grpc",
});

const client = new ThreatServiceClient(transport);

interface UseGrpcQueryOptions<T> {
  onSuccess?: (data: T) => void;
  onError?: (error: Error) => void;
  enabled?: boolean;
}

function useGrpcQuery<TRequest, TResponse>(
  method: (client: any, request: TRequest) => Promise<{ response: TResponse }>,
  request: TRequest | null,
  options?: UseGrpcQueryOptions<TResponse>
) {
  const [data, setData] = useState<TResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    if (!request || options?.enabled === false) {
      return;
    }
    
    let cancelled = false;
    
    async function fetch() {
      setLoading(true);
      setError(null);
      
      try {
        const { response } = await method(client, request);
        if (!cancelled) {
          setData(response);
          options?.onSuccess?.(response);
        }
      } catch (err) {
        if (!cancelled) {
          setError(err as Error);
          options?.onError?.(err as Error);
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    }
    
    fetch();
    
    return () => {
      cancelled = true;
    };
  }, [JSON.stringify(request)]);
  
  return { data, loading, error, refetch: fetch };
}

// Usage in component
function ThreatList() {
  const { data, loading, error } = useGrpcQuery(
    (c, r) => c.listThreats(r),
    { pageSize: 50, severity: "CRITICAL" }
  );
  
  if (loading) return <Spinner />;
  if (error) return <ErrorDisplay error={error} />;
  
  return (
    <ul>
      {data?.threats.map(t => (
        <ThreatItem key={t.threatId} threat={t} />
      ))}
    </ul>
  );
}

Example 2: React Subscription Hook for Streaming

import { useState, useEffect, useRef, useCallback } from "react";
import { GrpcWebFetchTransport } from "@protobuf-ts/grpcweb-transport";
import { ThreatServiceClient } from "./proto/threat.client";
import type { Threat } from "./proto/threat";

const transport = new GrpcWebFetchTransport({
  baseUrl: process.env.REACT_APP_API_URL || "/grpc",
  fetchInit: {
    credentials: "include",
  },
});

const client = new ThreatServiceClient(transport);

interface StreamState {
  connected: boolean;
  lastMessage: Threat | null;
  error: Error | null;
}

function useThreatStream(deviceId?: string) {
  const [threats, setThreats] = useState<Threat[]>([]);
  const [state, setState] = useState<StreamState>({
    connected: false,
    lastMessage: null,
    error: null,
  });
  const streamRef = useRef<{ cancel: () => void } | null>(null);
  const retryTimeoutRef = useRef<number>();
  
  const connect = useCallback(() => {
    if (streamRef.current) {
      streamRef.current.cancel();
    }
    
    setState(s => ({ ...s, connected: true, error: null }));
    
    const stream = client.streamThreats({
      deviceId: deviceId || "",
    });
    
    stream.responses.onMessage((threat) => {
      setThreats(prev => [threat, ...prev].slice(0, 100));
      setState(s => ({
        ...s,
        lastMessage: threat,
      }));
    });
    
    stream.responses.onError((error) => {
      console.error("Stream error:", error);
      setState(s => ({
        ...s,
        connected: false,
        error: error as Error,
      }));
      
      // Auto-reconnect after 3 seconds
      retryTimeoutRef.current = window.setTimeout(() => {
        connect();
      }, 3000);
    });
    
    stream.responses.onComplete(() => {
      setState(s => ({ ...s, connected: false }));
    });
    
    streamRef.current = stream;
  }, [deviceId]);
  
  useEffect(() => {
    connect();
    
    return () => {
      if (streamRef.current) {
        streamRef.current.cancel();
      }
      if (retryTimeoutRef.current) {
        clearTimeout(retryTimeoutRef.current);
      }
    };
  }, [connect]);
  
  return {
    threats,
    isConnected: state.connected,
    lastMessage: state.lastMessage,
    error: state.error,
    reconnect: connect,
  };
}

// Component using the subscription
function ThreatDashboard() {
  const {
    threats,
    isConnected,
    error,
    reconnect,
  } = useThreatStream();
  
  return (
    <div className="dashboard">
      <div className="status-bar">
        <span className={`indicator ${isConnected ? "connected" : "disconnected"}`} />
        {isConnected ? "Connected" : "Disconnected"}
        {error && (
          <button onClick={reconnect}>Reconnect</button>
        )}
      </div>
      
      <div className="threat-feed">
        {threats.map(threat => (
          <ThreatCard key={threat.threatId} threat={threat} />
        ))}
      </div>
    </div>
  );
}

Example 3: Protobuf Generation for React

{
  "scripts": {
    "proto:generate": "protoc \
      --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
      --js_out=import_style=commonjs,binary:src/proto \
      --ts_out=service=grpc-web:src/proto \
      --proto_path=../protos \
      ../protos/**/*.proto"
  },
  "dependencies": {
    "@protobuf-ts/grpcweb-transport": "^2.9.0",
    "@protobuf-ts/runtime": "^2.9.0",
    "@protobuf-ts/runtime-rpc": "^2.9.0"
  }
}
// package.json scripts
// "proto:gen": "buf generate ../protos --template buf.gen.yaml"

// buf.gen.yaml
// version: v1
// plugins:
//   - plugin: es
//     out: src/proto
//     opt: target=ts
//   - plugin: connect-es
//     out: src/proto
//     opt: target=ts

Common Mistakes

  1. Not handling stream reconnection — browser tabs can go to sleep, network can change. Implement automatic reconnection with exponential backoff.
  2. Forgetting to cancel streams on unmount — if a component unmounts without canceling its stream, the connection leaks. Always cancel in useEffect cleanup.
  3. Mixing REST and gRPC-Web URLs — gRPC-Web needs a different URL (Envoy proxy endpoint) than REST. Configure baseUrl correctly per environment.
  4. Using complex state management for stream data — for simple cases, React state + useEffect is sufficient. For complex cases, use Zustand or Jotai.
  5. Ignoring binary vs JSON format — gRPC-Web can use binary (smaller) or JSON (debuggable). Use binary in production, JSON in development.

Practice Questions

  1. How do you generate TypeScript gRPC-Web clients from protobuf?
  2. What is the pattern for React hooks with gRPC streaming?
  3. How do you handle reconnection in gRPC-Web streams?
  4. Why should you cancel streams in useEffect cleanup?
  5. How does gRPC-Web state management differ from REST?

Challenge: Build a React dashboard with 3 gRPC-Web streams: threat alerts, device status changes, and scan progress. Each stream uses a custom hook with reconnection logic. The dashboard displays connection status per stream and auto-reconnects on failure.

Mini Project

Build a complete React + gRPC-Web application with: generated TypeScript clients from protobuf, custom hooks for unary and streaming calls, connection status indicators, auto-reconnection with exponential backoff, and a real-time threat dashboard with live updates.

FAQ

Can I use gRPC-Web with React Native?

React Native doesn't support the Fetch API and ReadableStream used by gRPC-Web. Use native gRPC with @grpc/grpc-js for React Native.

How do I handle loading states with gRPC-Web?

Use React Query or SWR for unary calls. These libraries provide caching, deduplication, and loading states out of the box.

What is the best state management for gRPC-Web streaming?

Zustand or Jotai work well for shared stream state. For component-local streams, useState + useRef is sufficient.

How do I test React gRPC-Web components?

Use MSW (Mock Service Worker) to intercept gRPC-Web requests and return mock responses. This works in both Jest and Cypress.

Can I use gRPC-Web with Suspense?

Yes, wrap your gRPC-Web calls with useSuspenseQuery from React Query or create a custom Suspense integration using use.

What's Next

Learn about gRPC-Web fundamentals

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro