May 9, 2023•blog
The scheduling tool this is part of dates back to 2017, and its first interface was jQuery and D3 — direct DOM manipulation, with the data and the rendering hopelessly entangled. Replacing that with React was an enormous relief, but it surfaced a problem that was new at the time.
A React app needs to read data from a server, cache it so two components don’t fetch the same thing twice, and re-render when that data changes. The awkward part isn’t any one of those — it’s that the wiring to get there threads through every component that reads the data, so changing the data layer later turns into a large, mechanical edit across the whole app. Today you just hand react-query a queryKey, and stop thinking about it. Back then the dominant answer was “wire up Redux and a thunk middleware and some selectors,” which is a lot of ceremony to say “fetch this and tell me when it changes.” Hooks were new, the middleware ecosystem was a dozen competing libraries, and none of them quite fit. So I wrote my own. The cache underneath is a tidy enough object, but the part I’m still a little proud of is the tiny factory that binds it to React — a dozen lines that collapse the whole data layer into a list of one-line hooks.
This layer is older than React hooks. The first version was built to be consumed by class components, with their componentDidMount / componentWillUnmount lifecycle, and state explicitly managed by this.setState. What I built was a simple memoizer that wraps a factory:
class DataMemoizer<R> {
data?: Promise<R>;
listeners: EventListenerManager<{ change: "new" | "update" | "dirty"; data?: R }>;
wrap(data_factory: () => Promise<R>) // return type inferred — see below
get()
updateWithData(data: R)
dirty()
}
The detail I’d point to first: it memoizes the Promise<R>, not the resolved value. The cache is the in-flight-or-resolved promise. So if three components mount in the same tick and all call get(), the factory runs once and all three await the same request — deduplication falls out of the structure rather than needing a “is a request already pending?” flag.
The rest is a tiny state machine with three transitions, each of which fires a typed event to whoever is listening:
get() lazily fetches on first read (and re-emits the cached value on later reads).updateWithData() pushes a server reply straight into the cache and emits update — this is how a write result becomes the new truth without a refetch.dirty() invalidates, so the next get() refetches, and emits dirty. It only fires if there was actually data to throw away.So far, so good. This is basically the same shape as any modern caching layer modulo niceties. The elegant part arrived later, and for free.
React hooks shipped in early 2019, and I moved the interface onto them in 2020. The memoizer predated that by years: every consumer read its data in componentDidMount, tore down its subscription in componentWillUnmount, and held the result in this.state. Porting all of that to useState and useEffect meant rewriting the seam between the data layer and every component that touched it, and I sat down expecting a long, mechanical week of it.
It wasn’t. The whole seam collapsed into a single factory — one function that turns any wrapped getter into a React hook. This is the snippet that sold me on TypeScript:
function generateUseFunc<T>(getter: DataMemoizerWrappedFunction<T>) {
return function useGeneratedFunc() {
const [configData, setConfigData] = useState<null | T>(null);
useEffect(() => {
const uniqueId = generateUniqueId();
getter()
.then(setConfigData)
.then(() => getter.addChangeListener(uniqueId, (evt) => evt.data && setConfigData(evt.data)));
return () => getter.removeChangeListener(uniqueId);
}, []);
return configData;
};
}
It’s worth saying why. I’d misspent my formative years in Java, where the compiler only trusts you if you write everything down twice: a function that is also an event source isn’t a shape the language will say for you, so you declare an interface by hand and keep it in lockstep with the implementation forever. TypeScript hands you that type for free — it’s just ReturnType<DataMemoizer<T>["wrap"]>, whatever wrap returns, derived from the code instead of declared alongside it, so it can never drift. That was the moment the type system stopped feeling like paperwork and started feeling like leverage. And it’s what lets generateUseFunc adapt every endpoint to a useEffect-style hook one line each:
export const useConfigData = generateUseFunc(DataSource.config.getConfigData);
export const usePeopleData = generateUseFunc(DataSource.people.getPeople);
export const useEventData = generateUseFunc(DataSource.events.getEvents);
This turned a week’s worth of work into about an hour, and allowed me to slowly cut over my backend strangling-vine style over the better part of a year instead of forcing me to do it all at once.
I won’t oversell it. Not everything went through the memoizer — a few hooks hand-rolled useState + useEffect, one with a setInterval polling loop, one deliberately not clearing state on prop change to avoid a flicker. The presence of those one-offs is the honest tell: a uniform cache layer is exactly the thing you reach for once you have three bespoke ones, and I hadn’t pulled them in yet. There was no stale-while-revalidate, no retry policy, no devtools, no garbage collection of caches nobody was watching.
Which is the point, in a way. Years later I would replace this whole layer with openapi-fetch and TanStack Query — the renamed, grown-up react-query — and the mapping is almost exact: DataMemoizer is a queryClient entry, wrap + generateUseFunc is useQuery with a queryKey, updateWithData is setQueryData, and dirty() is invalidateQueries. The library does it declaratively, handles all the cases I punted on, and I deleted a few hundred lines to adopt it. That’s the right trade and I’d make it again.
But I keep a soft spot for the original, because writing it taught me the shape of the problem before a library handed me the answer. What I’d reach for now — a key-addressed cache of promises you can push into and invalidate, that notifies its subscribers on change — is something I had to understand before I could build, and that understanding is why TanStack Query reads to me as obvious rather than magic. The best argument for rolling your own, once, is that it’s the cheapest way to actually learn what the library is for.