tech-guide 10 min read

Splitting state by change frequency, not by domain: Building a Real-Time Fleet Tracker App From Scratch

Splitting state by change frequency, not by domain: Building a Real-Time Fleet Tracker App From Scratch

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.
  • useSyncExternalStore for subscribing components to that external store without fighting React's render model.
  • A requestAnimationFrame coalesced 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.

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.

Chapter 1: Workspace setup

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.

Chapter 3: The web workspace

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.

Chapter 4: Design tokens

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.

Chapter 5: The token showcase

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.

Chapter 6: The map view

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.

Chapter 7: Modeling the wire

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.

Chapter 8: The fleet buffer

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.

Chapter 10: The UI store

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.

Chapter 11: The React bridge

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.

Chapter 12: The entity layer

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.

Chapter 14: The entity HUD

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.

Chapter 15: Affiliation filters

// 06.contact

Bring your vision into focus.

From initial market strategy to deep code implementation, together we design and build full-lifecycle digital ecosystems. Drop a brief, a deck, or an open problem, and let Branden Builds do his thing.

Start a project

Let's get to work.