Real-time UI updates in React: best practices explained. Live data without janky renders.
Real-time UI updates in React represent one of the most challenging patterns to implement well. A janky or poorly architected WebSocket layer can tank your app's performance faster than almost anything else. We'll walk through battle-tested patterns that keep your live data flowing smoothly, your renders predictable, and your codebase maintainable.
The challenge with real-time data in React
When you integrate WebSockets or server-sent events into React, you're introducing data that arrives outside the normal component lifecycle. If you're not careful, you'll end up with:
- Multiple socket connections (one per component)
- Renders triggered at unpredictable moments
- Memory leaks from dangling listeners
- Race conditions when updates arrive during component transitions
Real-time UI updates in React demand a centralized architecture. The patterns we'll cover here come from production systems handling trading dashboards, ride-hailing fleet updates, and e-commerce inventory changes. They all share one principle: socket management belongs in infrastructure, not in components.
Keep the socket in one provider or hook
Your first instinct might be to create a socket connection in the component that needs it. Resist that. Instead, wrap your application with a single provider that owns the socket lifecycle.
This approach ensures one connection, one source of truth for lifecycle events, and a clean place to handle reconnection logic. Here's how we structure it:
import React, { createContext, useContext, useEffect, useRef } from 'react';
interface SocketContextType {
connected: boolean;
emit: (event: string, data: unknown) => void;
on: (event: string, handler: (data: unknown) => void) => () => void;
}
const SocketContext = createContext(null);
export const SocketProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const socketRef = useRef(null);
const [connected, setConnected] = React.useState(false);
const handlersRef = useRef>>(new Map());
useEffect(() => {
const ws = new WebSocket(process.env.REACT_APP_WS_URL || 'ws://localhost:3001');
ws.onopen = () => {
console.log('Socket connected');
setConnected(true);
};
ws.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data);
const handlers = handlersRef.current.get(type) || new Set();
handlers.forEach((handler) => handler(payload));
};
ws.onclose = () => {
setConnected(false);
// Implement exponential backoff reconnection here
setTimeout(() => {
// Reconnect logic
}, 3000);
};
socketRef.current = ws;
return () => {
ws.close();
};
}, []);
const emit = (event: string, data: unknown) => {
if (socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(JSON.stringify({ type: event, payload: data }));
}
};
const on = (event: string, handler: (data: unknown) => void) => {
if (!handlersRef.current.has(event)) {
handlersRef.current.set(event, new Set());
}
handlersRef.current.get(event)!.add(handler);
return () => {
handlersRef.current.get(event)?.delete(handler);
};
};
return (
{children}
);
};
export const useSocket = () => {
const ctx = useContext(SocketContext);
if (!ctx) throw new Error('useSocket must be used within SocketProvider');
return ctx;
};This pattern centralizes connection management and gives you one place to add authentication, heartbeat logic, and reconnection strategies.
Write updates into a store, not component state
The moment data arrives via the socket, you should dispatch it to a store rather than updating component state directly. This decouples your rendering logic from the timing of socket events.
We recommend using a lightweight state management library like Zustand, or building a custom hook with a reducer pattern if you want minimal dependencies. Here's a Zustand example handling a live order feed:
import { create } from 'zustand';
interface Order {
id: string;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered';
total: number;
updatedAt: number;
}
interface OrderStore {
orders: Map;
updateOrder: (order: Order) => void;
removeOrder: (id: string) => void;
getOrder: (id: string) => Order | undefined;
}
export const useOrderStore = create((set, get) => ({
orders: new Map(),
updateOrder: (order: Order) =>
set((state) => {
const newOrders = new Map(state.orders);
newOrders.set(order.id, order);
return { orders: newOrders };
}),
removeOrder: (id: string) =>
set((state) => {
const newOrders = new Map(state.orders);
newOrders.delete(id);
return { orders: newOrders };
}),
getOrder: (id: string) => {
return get().orders.get(id);
},
}));By routing socket updates through your store, you gain several benefits: you can batch updates, add logging or analytics, validate data before it hits the UI, and make your store the single source of truth. Components then subscribe to the store rather than listening directly to the socket.
Subscribe components to the slices they need
Don't subscribe entire components to the whole store. Instead, use selectors to let each component depend only on the data it actually renders. This minimizes unnecessary re-renders.
Here's a component that listens only to orders with a specific status:
const useShippedOrders = () => {
return useOrderStore((state) =>
Array.from(state.orders.values()).filter((order) => order.status === 'shipped')
);
};
export const ShippingDashboard: React.FC = () => {
const shippedOrders = useShippedOrders();
return (
<div>
<h3>Orders ready to ship: {shippedOrders.length}</h3>
<ul>
{shippedOrders.map((order) => (
<li>
Order {order.id} — ${order.total}
</li>
))}
</ul>
</div>
);
};Now hook this component into the socket provider at the root level:
export const OrderFeedListener: React.FC = () => {
const socket = useSocket();
const updateOrder = useOrderStore((state) => state.updateOrder);
useEffect(() => {
const unsubscribe = socket.on('order:updated', (order: Order) => {
updateOrder(order);
});
return unsubscribe;
}, [socket, updateOrder]);
return null;
};
export const App = () => {
return (
);
};This structure separates concerns: the listener component handles the socket subscription, the store holds the data, and UI components query only what they need. Each piece can be tested independently.
Clean up subscriptions on unmount
This is where most real-time React apps leak memory. Every subscription must be unsubscribed when the component unmounts. The patterns above already handle this through the return value of useEffect and the unsubscribe function returned by socket.on().
Here's a more complex example with multiple subscriptions:
export const OrderTracker: React.FC<{ orderId: string }> = ({ orderId }) => {
const socket = useSocket();
const order = useOrderStore((state) => state.getOrder(orderId));
const updateOrder = useOrderStore((state) => state.updateOrder);
useEffect(() => {
// Subscribe to updates for this specific order
const unsubOrderUpdate = socket.on('order:updated', (data: Order) => {
if (data.id === orderId) {
updateOrder(data);
}
});
// Subscribe to tracking events
const unsubTracking = socket.on('tracking:event', (event: TrackingEvent) => {
if (event.orderId === orderId) {
// Handle tracking update
}
});
// Return cleanup function
return () => {
unsubOrderUpdate();
unsubTracking();
};
}, [orderId, socket, updateOrder]);
if (!order) return <div>Loading order...</div>;
return (
<div>
<strong>Order {order.id}</strong>
<p>Status: {order.status}</p>
</div>
);
};The cleanup function ensures that when the component unmounts (or when orderId changes), the old listeners are removed. Without this, you'll accumulate handlers over time and your memory usage will grow unbounded.
Common mistakes
Creating a new socket in every component: This leads to multiple connections and makes it impossible to coordinate state. Always create one socket per application instance.
Updating component state from the socket: If you do useState in a listener, you're bypassing your store and creating race conditions. The socket should only write to your store.
Forgetting to unsubscribe: This is subtle because your app might work fine during development, but memory usage will creep up in production. Always test component mount/unmount cycles.
Not handling reconnection: Networks are unreliable. Your socket provider should implement exponential backoff and notify the UI when reconnecting, so users know why they're seeing stale data.
Broadcasting all updates to all subscribers: If your socket emits every field change to every connected client, you'll overwhelm the network and the browser. Batch updates and only send deltas when possible.
Key takeaways
- Centralize socket management in a provider or hook; never create connections inside components.
- Route all incoming data through a store (Zustand, Redux, or a custom reducer). This decouples timing from rendering.
- Components subscribe to store selectors, not to the socket directly. Use fine-grained selectors to avoid unnecessary re-renders.
- Always clean up subscriptions in the return value of
useEffect. Test mount/unmount cycles to catch leaks early. - Implement reconnection logic, connection status indicators, and data validation at the provider level, not in individual components.
- Consider batching updates on the server side to reduce the volume of messages sent to clients.






Comments
Loading comments…