DEV Community

Roman Kostetskyi
Roman Kostetskyi

Posted on

Stale By Design

A single status field that lets unrelated parts of an app mark cached data stale, without knowing who reads it.

elsewhere: an unrelated action fires → state: status becomes staleon mount: reload runs, status becomes fresh

The problem

Say your app caches a list of boards in the store. You load it once and read it everywhere else, which is normal.

Then someone renames a team member. Or switches teams. The cached list is now wrong, but nothing tells you that. It just sits there, out of date, until something forces a reload.

The two obvious fixes are both bad. Refetch on every possible change, and you send requests nobody asked for. Or add a manual refetch call in every place that could affect boards: the rename handler, the team-switch handler, and whatever gets added next quarter. Now a handful of files outside boards have to remember it exists. Forget one of them, and the list goes stale again with no warning.

The idea

Split it into two separate jobs. Saying data is stale should be cheap: any part of the app can do it, without knowing who else reads that data. Actually reloading it is not cheap, so it should only happen when something on screen is waiting for it.

One field carries the first job, called status. The rest of this post walks through how that plays out in code.

The code

the state

export type ReloadStatus = 'idle' | 'stale' | 'loading' | 'fresh' | 'error';

export interface BoardsState {
  entities: Board[];
  status: ReloadStatus;
}
Enter fullscreen mode Exit fullscreen mode

You could model this as two booleans instead, but two flags can drift out of sync with each other. This field can't. It's always exactly one of five states, never some combination that doesn't make sense.

marking it stale

invalidateOnDeps$ = createEffect(() =>
  this.actions$.pipe(
    ofType(switchTeam, renameMemberSuccess),
    map(() => invalidateBoards()),
  ),
);
Enter fullscreen mode Exit fullscreen mode

Any action listed here marks boards stale. switchTeam and renameMemberSuccess don't know boards exist; they just fire, and this effect reacts to them. Note it's renameMemberSuccess, not the request to rename. Boards shouldn't go stale before the rename actually went through.

on(invalidateBoards, (state): BoardsState => ({
  ...state,
  status: state.status === 'loading' ? state.status : 'stale',
}))
Enter fullscreen mode Exit fullscreen mode

One exception: if a load is already running, don't knock it back to stale out from under itself.

loading it back

on(loadBoards,        (state) => ({ ...state, status: 'loading' }))
on(loadBoardsSuccess, (state, { entities }) => ({ ...state, entities, status: 'fresh' }))
on(loadBoardsFailure, (state) => ({ ...state, status: 'error' }))
Enter fullscreen mode Exit fullscreen mode

'fresh' only happens after a real success. A failed load goes to 'error', not back to 'fresh'.

triggering the reload

Whatever this does internally, the outcome is one rule: however many components are watching boards, a single stale flip results in exactly one loadBoards dispatch, not one per component.

activate(destroyRef: DestroyRef): void {
  this.shared$ ??= this.status$.pipe(
    distinctUntilChanged(),
    filter((status) => status === 'stale'),
    tap(() => this.store.dispatch(loadBoards())),
    share({ resetOnRefCountZero: true }),
  );

  const subscription = this.shared$.subscribe();
  destroyRef.onDestroy(() => subscription.unsubscribe());

  this.status$.pipe(take(1)).subscribe((status) => {
    if (status === 'idle' || status === 'error') {
      this.store.dispatch(loadBoards());
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

Every component that shows boards calls activate() in ngOnInit. share({ resetOnRefCountZero: true }) is what makes that guarantee hold: every call subscribes to the same underlying stream instead of starting a new one.

That live part only reacts to 'stale', never to 'error'. An error comes from this same load failing, so retrying it live the same way would just fail and retry forever. 'idle' and 'error' are handled separately, once, by the last part: a check that runs the moment a component mounts, so a page opened fresh doesn't just sit there showing an empty list.

private readonly boardsFacade = inject(BoardsFacade);
private readonly destroyRef = inject(DestroyRef);

ngOnInit(): void {
  this.boardsFacade.activate(this.destroyRef);
}
Enter fullscreen mode Exit fullscreen mode

The raw actions and the status selector aren't exported from the facade. Only entities$, status$, and activate() are meant to be used outside of it, so there isn't much to reach for that could be misused.

Is it worth it

This is a fair amount of code for one cached list. It starts paying off once you have several caches that all need to react to the same kind of change. A rename, for example, might need to invalidate boards, projects, and permissions, and none of those three should have to know about the other two.

If you're on Angular, look at TanStack Query first. Call invalidateQueries, and a query refetches on its own the next time something asks for it. Same idea, with far less code, as long as you're willing to let it own your data fetching. This is here for when that's not realistic: you're already deep in NgRx, and swapping your data layer isn't happening this quarter.


Inspired by a real stale-cache bug, rebuilt as a small standalone example. Full code on GitHub →

Top comments (0)