Skip to main content

Command Palette

Search for a command to run...

Root-default / screen-claim tracking: redesigning Expo Router analytics with two AIs in the loop

Updated
14 min read
Root-default / screen-claim tracking: redesigning Expo Router analytics with two AIs in the loop

Where I picked up the MR

A new requirement had come in: every Expo Router screen should fire a screen_view event, with a handful of screens attaching extra payload. I sketched an initial direction with a more junior teammate — fire once from the root on route change, keep the extras in a small global store, write into it from the screens that need them — and he picked up the MR and landed a first pass. I had a hunch the sketch would run into duplicates or cross-screen leakage once we hit real async data, but we hadn't talked through the edge cases together before the first implementation attempt.

When I later answered the distress call and pulled the branch down to review, the hunch was borne out. In dev, an article visit fired three screen_view events: one empty on mount, one correct once GraphQL resolved, one empty again on cleanup. Other screens (auth, legal, bookmark lists, tab landings) fired zero or one events depending on whether anything upstream had written the shared tracking store in the last few moments. The pattern wasn't production-facing yet, but the naive version of the approach I'd suggested wasn't going to get us there.

Reworking it turned into a two-model architecture session: one planning round with Opus 4.7, another with GPT-5.4, and a handful of review cycles between them. What came out is a pattern worth writing down — not because the code is revolutionary, but because the ownership model it settled on is genuinely different from what I'd sketched, and the path to it involved more iteration than I'd have guessed.

Codebase context

The app is the Expo workspace of a monorepo — file-based routes under packages/expo/app/, roughly fifty screens, including a catch-all [...path] dynamic route used for articles and product listings. The root layout at packages/expo/app/_layout.tsx is where cross-cutting concerns (auth, CMP, Firebase init, navigation-level tracking) already lived. Analytics events go to Firebase via a thin wrapper at packages/expo/features/tracking/utils/createTrackingHandlers.ts.

A small minority of screens genuinely need extra screen_view params:

  • Dynamic article / product screens need articleSaved, filter_active, sortby_active derived from GraphQL data, local state and URL state.

  • Bookmark list screens need profileSavedArticleCount / profileSavedProductCount from local state.

Everything else — legal pages, auth screens, tab landings, the video and assistant screens — just needs a bare screen_view with the standard path/user context.

The requirements

They sounded simple:

  1. Fire screen_view on every file-based route.

  2. Attach extra params only for some screens.

  3. Correct timing — don't fire before async params are ready.

  4. Never leak params from one screen into another.

Rule 1 alone is trivial: emit from the root on every pathname change. Rule 2 alone is trivial too: screens call the tracking util themselves. Rules 3 and 4 are where it gets hard. "Ready" and "isolation" don't coexist cleanly if screens share any state, and they don't compose cleanly if every screen has to opt in.

The first pass

The initial implementation at _layout.tsx — a clean version of the sketch I'd proposed — looked like this (simplified):

const { screenViewTrackingData } = useScreenViewTrackingDataStore()
const { trackingActionHandler } = createTrackingHandlers({
  eventName: SCREEN_VIEW,
  trackingData: screenViewTrackingData,
})

useEffect(() => {
  trackingActionHandler()
}, [trackingActionHandler, screenViewTrackingData])

A global Zustand store held extras. Deeply nested components wrote into it — DynamicScreenComponent set filter and bookmark state; a dedicated useSetBookmarkTrackingData hook set the bookmark counts. The root layout fired screen_view every time the store changed.

The triple-fire falls out of that shape — not out of anything wrong with the implementation, but out of the shape of the design itself. Every store mutation is another useEffect trigger.

  1. Article screen mounts → GraphQL in flight → store still {} from last cleanup → screen_view fires with {}.

  2. GraphQL resolves → setScreenViewTrackingData({ articleSaved, filter_active, ... })screen_view fires again, this time with the real payload.

  3. User navigates away → unmount cleanup calls resetScreenViewTrackingData(){} again → screen_view fires, blank.

The silent leakage was harder to trigger but worse. A tab blur listener also cleared the store. Across a fast tab switch, a bookmark list's write could land on the wrong screen's event, or a rapid A→B→A navigation could interleave setScreenViewTrackingData calls such that the payload for A showed up on B. The store was global and last-writer-wins; correctness needed to be route-scoped.

The core ambiguity

The old model couldn't express the difference between two states that look identical from outside:

  • This route has no extras. Fire a default screen_view with an empty payload.

  • This route has extras, but they're not ready yet. Don't fire yet; wait.

The store-based design sees both as "store is empty." Any fix had to give these two cases distinct representations.

Round 1 — identifying the ownership problem (Opus 4.7)

The first planning pass was with Opus 4.7. Its initial instinct — obvious in retrospect — was to go fully per-screen: every route file calls a useScreenView() hook, explicit, nothing shared. That's the cleanest ownership model possible; params live in the hook's closure and can't leak.

I pushed back: roughly fifty route files is a lot of boilerplate, and a new route added without the hook would silently drop from analytics. The hybrid variant landed next. The root emits by default. Screens opt in only when they need custom timing or params. The opt-in hook would "claim" the active route; the root would check the claim before firing the default.

This was the ownership insight: emission centralised, payload localised. Both halves mattered. Centralised emission gave us the single firing site to audit and the zero-boilerplate default. Localised payload gave us the isolation — a screen's params never leave its closure until it chooses to register them against the current route identity, and the registration is keyed by route so another screen can't overwrite it.

Round 2 — comparing with GPT-5.4

Separately I asked GPT-5.4 to tackle the same problem cold. It converged on the same high-level architecture, which was itself a useful signal. But it diverged on four decisions worth tabulating:

Decision Opus 4.7 first draft GPT-5.4 proposal
Firing site when extras exist The opt-in hook fires itself Root emitter fires for every case, reading from a registry
Opt-in API shape useScreenView(params, { ready, dedupeKey? }) useRegisterScreenViewContext({ enabled, isReady, getTrackingData, identity })
Dispatch utility Reuse createTrackingHandlers Dedicated trackScreenView wrapper
Route identity Pathname (with optional dedupeKey) Composite key: pathname + search params

The firing-site decision was the most interesting. Opus's design had two dispatch sites (root for defaults, hook for claimed) which is simpler to wire — no pub-sub needed, React's effect graph is the coordination. GPT's design has exactly one dispatch site but needs the registry to be subscribable. After weighing both I went with GPT's single-site model: one function to audit, consistent logging format, one test mock target. The cost — a zustand subscription in the emitter and a getEntry helper on the store — is small.

The dedicated trackScreenView wrapper was the closer call. Earlier I'd argued against it: createTrackingHandlers already handles Firebase plumbing. But at fifteen lines the wrapper gave the emitter tests exactly one mock target (jest.mock('./trackScreenView')) instead of having to stub the return shape of the generic factory. I kept it.

The route-identity choice was a clear correctness win for the composite key. Two different products on the same /store/[...path] catch-all would look identical to a pathname-only registry. With { pathname, routeParams } normalised and serialised by getScreenViewRouteKey, they're distinct, and the emitter correctly fires one event per product instance.

dedupeKey from Opus's API was deferred and later removed — once the route key was composite, it wasn't pulling its weight.

Neither model produced the final answer alone. Opus caught the ownership insight cleanly but left the dispatch-site choice unexamined. GPT's proposal had the sharper decomposition on every sub-point but was more verbose (the four-field structured API was over-designed for the concrete needs). The post-comparison edits merged Opus's simpler (params, { ready }) signature with GPT's composite key, single-site emission, and dedicated dispatcher.

Round 3 — refinement under review

The mop-up round after a working first implementation. Three sharp edges came up in review.

Churn from inline object literals. Callers pass { articleSaved: isArticleBookmarked, ... } into useScreenView. Each render creates a new object identity, so the data-sync useEffect re-runs every render, calling setEntry with structurally-equal but identity-different params. Each call built a new Map and woke every zustand subscriber — including the emitter's evaluate().

React Compiler is enabled on this project so caller-side memoisation wasn't an option we wanted to manually add — leaning on stabilised identity at the consumer would have fought the compiler's own auto-memoisation. The right place to fix it was the coordination point itself. setEntry now short-circuits when the new entry is shallow-equal to the existing one and returns the same state reference, so zustand doesn't notify subscribers. Consumers keep their idiomatic inline-literal style; the store does the right thing structurally.

Payload flicker on prop changes. The first draft of the opt-in hook used a single useFocusEffect for both the lifecycle (register on focus, clear on blur) and the data sync (re-register when ready or params changed). The problem: useFocusEffect runs cleanup then body on every dep change, so a prop update briefly emptied the registry entry and re-filled it. Downstream dedup prevented a spurious event, but the flicker was a readability problem.

Split responsibilities: useFocusEffect owns the focus boundary (set on focus, clear on blur/unmount); a separate useEffect syncs payload and readiness in place while focused, gated by an isFocusedRef. Two effects, one concern each.

dedupeKey was dead API. Once the route key was composite, there was no case left for a secondary deduplication axis. Keeping the option made the hook's signature look more capable than it was. Removed.

Stronger typing for tracking data. A later review round tightened ScreenViewTrackingData from a loose Record<string, string | number | boolean | undefined> to a named interface with the five known keys (articleSaved, filter_active, sortby_active, profileSavedArticleCount, profileSavedProductCount). A const-tuple of those keys drives the shallow-equality check and is satisfies-checked against the interface — so adding a new tracking key becomes a compile-time prompt to update the dedup list rather than a latent bug waiting to surface. Small change, real guardrail.

The final pattern

Three files carry the weight. The emitter lives in the root layout:

useEffect(() => {
  let cancelled = false

  const fire = (trackingData: ScreenViewTrackingData) => {
    if (cancelled || lastFiredRouteKeyRef.current === routeKey) return
    lastFiredRouteKeyRef.current = routeKey
    void trackScreenView(trackingData)
  }

  const timeoutId = setTimeout(() => {
    const entry = registry.getState().getEntry(routeKey)
    if (!entry) return fire({})
    if (entry.ready) fire(entry.trackingData)
  }, 0)

  const unsubscribe = registry.subscribe(() => {
    const entry = registry.getState().getEntry(routeKey)
    if (entry?.ready) fire(entry.trackingData)
  })

  return () => { cancelled = true; clearTimeout(timeoutId); unsubscribe() }
}, [pathname, routeKey])

The registry — a small zustand store that is the one coordination point between the emitter and the claiming hooks:

export const useScreenViewRegistryStore = create<ScreenViewRegistryState>(
  (set, get) => ({
    entries: new Map(),
    setEntry: (routeKey, entry) =>
      set(state => {
        const previous = state.entries.get(routeKey)
        if (areEntriesEqual(previous, entry)) return state // idempotent no-op
        const next = new Map(state.entries)
        next.set(routeKey, entry)
        return { entries: next }
      }),
    clearEntry: routeKey =>
      set(state => {
        if (!state.entries.has(routeKey)) return state
        const next = new Map(state.entries)
        next.delete(routeKey)
        return { entries: next }
      }),
    getEntry: routeKey => get().entries.get(routeKey),
  }),
)

The opt-in hook:

export const useScreenView = (trackingData = {}, { ready = true } = {}) => {
  const routeKey = getScreenViewRouteKey({ pathname, routeParams })

  useEffect(() => {
    if (!isFocusedRef.current) return
    registry.setEntry(routeKey, { ready, trackingData })
  }, [routeKey, ready, trackingData])

  useFocusEffect(useCallback(() => {
    isFocusedRef.current = true
    registry.setEntry(routeKey, { ready, trackingData })
    return () => {
      isFocusedRef.current = false
      registry.clearEntry(routeKey)
    }
  }, [routeKey]))
}

And the actual call sites are boring, which is the point:

// ArticleBookmarksList.tsx
useScreenView({ profileSavedArticleCount: count.toString() })

// DynamicScreenComponent.tsx
useScreenView(
  { articleSaved: isArticleBookmarked, filter_active, sortby_active },
  { ready: shouldTrack && !!trackingData },
)

Every other route file — around forty-five of them — contributes nothing. The root emitter handles them.

The setTimeout(0), without apology

The timeout is a real coordination primitive, not a hack. Here's what it's doing:

React flushes passive effects child-first, parent-last. When the pathname changes, the new screen commits, its useFocusEffect runs, and it calls setEntry — all before the root's useEffect body runs. In the common case, by the time the emitter evaluates, the claim is already in the registry.

The setTimeout(0) still earns its place for three edge cases:

  • Concurrent-mode effect deferrals, where the strict child-before-parent order is not guaranteed.

  • Screens that have no useScreenView call at all; the timeout is what signals the emitter to fire the default.

  • Screens that register with ready: false; the timeout correctly decides nothing, and the subscription waits for ready to flip.

A microtask would also work. setTimeout(0) is easier to test with Jest fake timers (jest.runAllTimers()) and easier to explain to the next engineer. Subscribing directly to React Navigation's state-change events was the main alternative, but it would have coupled the emitter to a deeper API for marginal gain.

Testing

Two patterns made the hook layer testable without a real navigator.

First, useFocusEffect is mocked to capture its callback into a module-level array, which lets each test trigger focus manually:

const focusEffectCallbacks: (() => void | (() => void))[] = []
jest.mock('expo-router', () => ({
  usePathname: jest.fn(),
  useGlobalSearchParams: jest.fn(),
  useFocusEffect: jest.fn(cb => { focusEffectCallbacks.push(cb) }),
}))

Second, the emitter tests use Jest's fake timers around act() to drive the setTimeout(0) deterministically:

renderHook(() => useScreenViewEmitter())
act(() => { jest.runAllTimers() })
expect(trackScreenView).toHaveBeenCalledWith({})

Between those two patterns the covered cases are: unclaimed route fires default; claimed-and-ready fires payload; claimed-but-not-ready waits then fires when ready flips; route change with different search params produces independent events with no payload overlap. The registry's own unit tests handle the idempotency short-circuit; getScreenViewRouteKey has its own tests for key stability under param-order variation.

Retrospective

Three takeaways after the dust settled.

One useful round with one model. Two rounds with two models is better. Opus and GPT converged on the hybrid architecture independently — that convergence was itself a signal that the pattern was right. But on four sub-decisions they disagreed, and three of those four went to GPT's proposal. The comparison surfaced tensions I wouldn't have noticed consulting either alone. Single-firing-site vs two-site, in particular, never came up in my solo planning with Opus — it only appears when you have an alternative to compare against.

The coordination point is usually the right place to fix redundancy. The first instinct for the inline-literals churn was to stabilise identity at the hook. With React Compiler enabled that would have been both unnecessary and in tension with the compiler's own memoisation. The right fix was a shallow-equality short-circuit at the store. Fixing at the coordination boundary let consumers stay idiomatic and kept the dedup logic in one place.

Patterns beat rules. The final architecture isn't a new abstraction — it's root-default plus route-scoped opt-in, which matches how Expo Router itself works (routes are implicit by file layout, explicit only when they need configuration). Matching the framework's grain means the pattern is easier to remember, explain, and extend. If you're building an app with many routes where every one should emit an analytics event but only some carry extra payload — and especially if any of that payload is asynchronous — root-default / screen-claim is worth reaching for.