I've been building and learning a lot of new tech and coding paradigms lately, and I started this blog to keep track of it all somewhere I can actually reference later. This post is about the philosophy and the decisions. The technical step by step lives in /docs in the repo.
We're going to build a fleet tracker fed by telemetry over WebSockets, centered on the Chesapeake Bay, where I grew up. Here's what we'll dig into:
- A two-tier state split. A high-frequency telemetry buffer lives outside React in a closure-based store, and UI state (selection, filters, connection status) lives in Zustand, so a 400 ms data firehose never forces a full app re-render.
useSyncExternalStorefor subscribing components to that external store without fighting React's render model.- A
requestAnimationFramecoalesced update loop, so many WebSocket frames collapse into at most one render per animation frame. - A semantic design token system, shared between CSS and the map's own imperative paint layer, so the UI and the map stay visually consistent.
- Domain logic that stays pure. Filtering, GeoJSON shaping, and the "what's visible right now" scan all live outside React as plain functions, so there's one definition of visible instead of three that can drift.
Managing Client Side State
For most of my career I've managed client side state by domain, using more or less one tool. Redux, Zustand, Context API, whatever. Honestly, I find myself needing these tools less and less these days as React (or whatever flavor of library) has come a long way. You know the setup: break the domain into slices, write hooks for them, ship it. It's a clean, organized mental model.
Then I started doing telemetry work with high-velocity data, and it broke down. Slicing by domain assumes every slice wants the same thing when it changes, and that assumption stops being true the second one of those slices is moving twice a second. So I started asking a different question: how often does this data change?
- The fleet moves every 400 ms. Fourteen entities, every one of them with a new position, heading, and speed, two and a half times a second, forever.
- Selection, connection status, and filters change a handful of times in an entire session, and only ever when a human clicks something.
Those two cases deserve two different answers. Data that changes because someone interacted with it should trigger a re-render. That's exactly what React was built for. Data that changes 150 times a minute should stay away from the render cycle entirely, because a re-render is an expensive way to move a dot four pixels.
So the split is the whole architecture: the fleet lives in a mutable Map outside React (anyone with deep JS knowledge outside frameworks already knows this trick), and the UI state lives in Zustand, where re-rendering is the correct response to a change. Everything below is downstream of that one decision.
Project Overview
I started writing this as a step by step tutorial and it got crazy long, so here's the summary and the chapters live in the repo. A Node WebSocket server pushes simulated vessels every 400 ms, and the client renders them on a MapLibre basemap with click-to-select, a telemetry HUD, and affiliation filters. We'll build it as a mini monorepo, if you will (I know, a bit of an oxymoron).
The stack: React 19, TypeScript, Vite, MapLibre GL, Zustand, and CSS Modules with custom properties. No UI kit, no map wrapper library, no data-fetching library. I wanted this to be about architecture, not about tools.
Repo: github.com/bbuilds/fleet-telemetry
Step-by-step docs: the full 15-chapter build
1. Workspace setup
This is a bare bones "mono" repo, so I'm using npm workspaces rather than Turborepo or Nx. There are two packages and no build graph worth orchestrating, so anything heavier would be ceremony for its own sake. I went with Biome for the same reason: one binary, one config file, one dependency to keep in sync between the server and the web app. Node version is pinned so the server and the app agree on what they're running.
2. The telemetry server
This is the only part of the repo where I went full AI, and if you look at the code you'll see why. Getting the route geometry and the tick path right was a bit over my head, and it wasn't the part I was here to learn. The server exists to produce a realistic feed and nothing else: vessels transiting the Chesapeake shipping channel into Naval Station Norfolk, broadcast on a fixed 400 ms tick. Copy it, run it, and move on. Everything after this chapter is client-side architecture.
Chapter 2: The telemetry server
3. The web workspace
A simple Vite, React, and TypeScript setup with an @/ path alias and two HTML entries, one for the app and one for the token showcase. Side note: I really love how powerful and easy to use Vite is. Two entries out of one config with no extra plumbing is the kind of thing that used to cost a whole afternoon.
4. Design tokens
I'll be honest, I set this up more as a learning thing. Tokens seem to be all the hype lately and this project is small enough that it's overkill, but it's structured properly: primitives, then semantics, then component-level tokens, all as CSS custom properties. The payoff shows up in Chapter 12, where the map needs the exact same colors as the UI but can't read CSS. Overall the CSS here is minimal and basic on purpose.
5. The token showcase
A page that exists purely to show off the design tokens, built as a second Vite entry. It's a side quest and mostly unrelated to the app, so you could skip it. The one piece worth keeping is token-values.ts, the single module that resolves tokens down to literal strings. That boundary matters because MapLibre evaluates paint properties GPU-side and can't take a var(), so exactly one file is allowed to cross from CSS into JS.
6. The map view
We're using MapLibre, which draws with WebGL, so it handles far more points than the DOM ever could by pushing vector tiles and layers onto the GPU. That means fleet size is essentially invisible to React. The job of this chapter is small but easy to get wrong: create the map instance exactly once, hand it a container, and tear it down correctly on unmount. React owns the map's lifetime, not its pixels.
7. Modeling the wire
Before any of the state machinery, we name the shapes coming off the socket: Entity, TelemetryEvent, and TelemetryFrame. This is the API contract between the server and the client, and writing it down first means the buffer, the store, and the map are all arguing about the same object. It's a short chapter, but it's the one that keeps the rest of the app from turning into any.
8. The fleet buffer
This is really the whole point of my post and repo. The fleet lives in a mutable Map inside a closure, outside React, and React only ever learns that something changed. Three details make it work.
First, notifications are buffered into one requestAnimationFrame per burst rather than one per frame applied, so a pile-up of socket messages costs a single notification. A nice side effect of rAF is that it doesn't fire in a background tab, so an idle tab stops doing work for free. Second, the subscribable snapshot is just a version counter, because useSyncExternalStore compares snapshots with === and calls the snapshot function on every render to do it. A number is the cheapest thing you can hand it. Third, toArray() caches per version, which is one allocation per tick instead of one per caller, and gives a stable array identity so nothing downstream loops comparing a fresh allocation to itself.
9. The telemetry client
Now let's define how we talk to the socket. createTelemetryClient is plain TypeScript: connect, reconnect with backoff, tear down. It has no dependency on React or the DOM tree, which keeps the transport testable and, as a bonus, makes it survive StrictMode's double-mount cleanly. Frames go to the fleet buffer and connection status goes to the UI store, which is the split from the top of the post showing up in the wiring.
Chapter 9: The telemetry client
10. The UI store
This is the other half of the split: a small Zustand store that is 100% driven by human interaction. It holds the selected entity, the affiliation filters, and the connection status. Filter sets are copied on write so that changing them produces a new identity and downstream memoization actually invalidates. Every value in here is allowed to cause a re-render, because that's the correct response when a person clicks something.
11. The React bridge
Time to connect everything with a few hooks. useTelemetry owns the socket's lifetime, so the client is created and destroyed with the app rather than with whatever component happened to need it. useFleetVersion and useFleetSelector are both direct calls to useSyncExternalStore, differing only in the snapshot function: one returns the version counter, the other returns a slice you care about. That's the whole bridge, and it's deliberately thin. The rule I stuck to is that hooks are the component's data layer, so a hook decides what to subscribe to and the component stays a pure function of props.
12. The entity layer
Here's where the fleet actually hits the screen. EntityLayer renders null, and exists purely to give MapLibre's sources, layers, and event handlers a place in the React tree with a real lifecycle. Each tick it shapes the visible fleet into GeoJSON and makes one setData call, so MapLibre draws the whole fleet from a single WebGL buffer and fleet size stays invisible to the DOM. Affiliation colors are resolved to literals from the token layer and evaluated GPU-side, which is why Chapter 5 needed that one JS boundary. This is also where selectVisible(filters) earns its keep: it's memoized on version and filters, and the map, the filter counts, and the selection guard all read the same scan.
13. Selection and focus
Time to build the first real user interaction. Click an entity and the map flies to it and draws a marching-ants selection ring around it. The interesting part is the guard: the selected vessel can get filtered out from under you, so selection has to reconcile against the same visible scan the map uses rather than keeping its own idea of what exists. Selection is also a perfect example of low-frequency state, since it changes when a human decides it does, so it lives in Zustand without a second thought.
Chapter 13: Selection and focus
14. The entity HUD
A panel showing the selected vessel's speed, heading, and position. This is the one place we deliberately let high-frequency data back into React, because a few numbers changing twice a second is cheap and it's what the panel is for. useEntityHud() returns exactly the props EntityHud takes, so the subscription is scoped to one entity's fields instead of the whole fleet. If nothing is selected, the HUD doesn't re-render at all.
15. Affiliation filters
The last piece is a toggle bar for friendly, neutral, hostile, and unknown, with live visible and total counts next to each one. Those counts are the payoff of having one shared derivation, since the bar reads the exact same selectVisible scan the map does and can't disagree with what's drawn. Toggling writes to the UI store, which invalidates the memo, which produces one new GeoJSON payload on the next frame. Two stores, one scan, and the firehose never touched the render tree.