This is another entry in the Inglorious Web series. It stands alone, but it leans on ideas from the first post, the architecture post, and There's No Such Thing As Local State.
Once Upon A Time, There Were Ducks
Back when Redux ruled the frontend, Erik Rasmussen proposed a convention called ducks: bundle a reducer, its action types, and its action creators into one file, and you'd get a portable, reusable unit of logic. Drop a cart.js duck into any app, and you'd have a working cart.
It didn't really happen. Ten years later, most teams still write a new reducer for every new app, and RTK slices — the closest thing we got to "ducks, but official" — remain bound to whatever RTK-specific data shape and API surface the project already committed to. Reusable business logic modules never became a thing.
I used to think the reason was structural: a reducer manages one specific slice of state, so it's more of a singleton than a template you can instantiate twice. That's not quite it, though. The deeper reason ducks never spread is duller and more honest: most application logic is domain-specific. A cart reducer encodes discount rules, shipping logic, inventory checks — assumptions that don't transfer to someone else's cart. There was rarely anything generic enough to reuse in the first place. Ducks weren't badly designed. They just didn't have much to package.
But there's a second, more technical reason ducks stayed inert, and it's the one this post is actually about.
Reducers Can't Get Their Hands Dirty
Redux reducers have to be pure. No async, no side effects, no randomness — that's not a style preference, it's load-bearing. Time-travel debugging, replay, predictable diffing: all of it depends on a reducer being a deterministic function of (state, action).
Which means a duck could never really own anything impure. You couldn't write a "geolocation duck" that just works — the actual browser API call, the watchPosition subscription, the cleanup — had to live somewhere else: a thunk, a saga, an epic, bolted on next to the reducer instead of inside it. The duck was only ever half a capability. The interesting half — the part that actually touches the world — always lived outside the store.
So when browser APIs, subscriptions, and other impure concerns needed a home, the store was structurally disqualified. They went somewhere else instead.
Hooks and Composables Found a Better Home — For a Reason
React's hooks and Vue's composables didn't just offer nicer syntax for the same idea. They gave impure logic something ducks never had: a lifecycle to pair with. useEffect's cleanup function, Vue's onUnmounted — these exist specifically to answer the question a duck could never answer: when does this subscription get torn down? A component mounting and unmounting is a real, meaningful lifecycle event, and hooks/composables hooked into it for free.
This is a genuine ergonomic win, and it's a big part of why libraries like VueUse and react-use took off — useGeolocation, useNetwork, usePageVisibility, useMediaQuery — all portable across any app built on that framework, all impure, all fine. If you're a Vue developer, VueUse composables are exactly as reusable to you as anything I'm about to describe.
But the price was fragmentation. Business state lives in the store. Browser state lives in scattered hooks. Two different mental models, two different testing strategies, two different places to look when something's wrong — and that's before you've imported a router, a form library, and three unrelated utility hooks, each with its own API. It's the same failure mode I described in There's No Such Thing As Local State: state that was supposed to stay contained didn't, and the cost shows up later as a debugging problem, not a design-time one. There, it was Context providers and useState calls quietly multiplying. Here, it's the same story one layer down — impure, subscription-shaped state fleeing the store entirely because the store had no room for it.
Entities Don't Need to Borrow a Lifecycle
Inglorious Web's entities aren't reducers, and they aren't hooks. As I covered in the architecture post, a type is just an object of event handlers, and an entity is a plain piece of state that a type governs. Handlers aren't required to be pure — they're wrapped in Mutative so you can mutate entity.value and get an immutable snapshot back, but nothing stops a handler from also calling navigator.geolocation.watchPosition.
That's the actual difference from ducks: entities were designed to welcome impure logic instead of quarantining it. And it's the actual difference from hooks: entities get an explicit lifecycle of their own — geolocationWatch / geolocationUnwatch — without needing a component to mount and unmount in order to get one.
Handlers being impure doesn't mean the system becomes unpredictable, either. Whatever a handler triggers — api.notify(...) — doesn't run immediately, it's enqueued. Events are processed one at a time, in the order they were enqueued, the same as any other event in the store. A geolocation callback firing whenever the browser feels like it is still funneled through the same deterministic queue that handles a button click, so the unpredictability of when the browser calls back never leaks into how the resulting update is applied. That determinism is what I leaned on for the accordion example in the local state post — it holds here too, impure source or not.
Here's Geolocation, roughly — the real events are geolocationRequest, geolocationWatch, and geolocationUnwatch, and the entity tracks isSupported, isLoading, isWatching, a normalized position, a normalized error, and the raw watchId:
export const Geolocation = {
create(entity) {
entity.isSupported = "geolocation" in navigator;
entity.isLoading = false;
entity.isWatching = false;
entity.position = null;
entity.error = null;
entity.watchId = null;
},
geolocationWatch(entity, options, api) {
if (!entity.isSupported || entity.isWatching) return;
entity.watchId = navigator.geolocation.watchPosition(
(position) => api.notify("geolocationPositionUpdate", position),
(error) => api.notify("geolocationError", error),
options,
);
entity.isWatching = true;
},
geolocationPositionUpdate(entity, position) {
entity.position = { coords: position.coords, timestamp: position.timestamp };
entity.isLoading = false;
},
geolocationUnwatch(entity) {
navigator.geolocation.clearWatch(entity.watchId);
entity.watchId = null;
entity.isWatching = false;
},
};
The impure part — the actual watchPosition call — is real and it's not hidden; it's not smuggled into whichever component happens to render first. The type owns it, explicitly, from geolocationWatch to geolocationUnwatch.
Wire it into a store and you get live position data the same way you'd get any other piece of state:
import { createStore } from "@inglorious/store";
import { Geolocation } from "@inglorious/web/geolocation";
const store = createStore({
types: { Geolocation },
autoCreateEntities: true,
});
store.notify("geolocationWatch");
const { position } = store.getEntity("geolocation");
No useEffect. No cleanup function tied to a render tree. No separate testing story from the rest of your app — it's the same trigger() helper from the first post, because a handler is still just a handler.
There's an Entity for That
This is what's actually behind the sensors I recently shipped: Compass, Geolocation, ElementSize, NetworkStatus, PageVisibility, MediaQuery — six browser capabilities that would traditionally be six separate hooks from two or three different libraries. They're not a separate package; they're a subfolder inside @inglorious/web itself, next to the router and the form helpers. That's a deliberate two-sided bet: modular, because if you never touch Compass it's never imported and never ships in your bundle; and batteries-included, because if you do need it, it's already sitting in the framework you already depend on — no new library to vet, no new API to learn, no new testing strategy to adopt.
Apple used to say "there's an app for that." Here, the pitch is smaller and, I think, more honest: there's an entity for that — not because entities solved reusability where ducks and hooks failed (ducks' problem was mostly that business logic doesn't generalize, and that's still true), but because impure, stateful browser concerns finally have a home that doesn't require fragmenting your architecture to get one.
What This Isn't
A few things I want to be upfront about, because I'd rather say them than have them show up in the comments:
- This isn't a claim that business logic is now reusable. A cart is still a cart; its rules still won't transfer between apps. That was never the part that was broken.
-
This isn't a claim of zero dependency. Entities depend on
@inglorious/store— a state container with an event queue — to exist. It's a smaller, more generic dependency than a full reactive runtime, but it's still a runtime you're depending on. What it doesn't depend on is worth being precise about, though: look back at theGeolocationobject above — it's a plain JS object with plain functions. Nothing in it callsuseState, subscribes to a reactivity graph, or assumes a component tree exists. You could paste it into a Node script, a web worker, or a test file and callcreate/geolocationWatch/geolocationUnwatchdirectly, no rendering environment required. A hook can't make that claim — strip away React anduseEffectdoesn't exist to call. - The ecosystem is younger. VueUse and react-use have years of community coverage. The sensors folder has six entries. If you need a hundred obscure browser APIs wrapped today, they'll get you there faster.
What I'd rather you take away is narrower than "reusability, solved": one store, one event model, one testing approach, for your sensors and your forms and your routing and your business entities — instead of assembling five libraries with five different mental models and hoping they compose.
Top comments (1)
This is a great read for anyone thinking about frontend architecture beyond the usual hooks + store patterns.
The idea of giving stateful browser capabilities their own entities and lifecycle is especially interesting. Instead of scattering subscriptions, cleanup, and browser APIs across components, the entity can own that responsibility while still flowing through the same event model.
I also appreciate the author being clear about the trade-offs rather than presenting it as a silver bullet.
Definitely worth reading if you're interested in Redux, state architecture, reusable browser capabilities, and designing more maintainable frontend systems.