Rabi Siddique
1264 words
6 minutes
UI Feature Flags — How We Ship WIP UX in ymax

When a new UX direction lands in the codebase before it is ready for users, we do not want two branches drifting apart for weeks. We also do not want half-finished screens to greet real users. The compromise we use in the ymax UI is a persisted console-toggle flag: merged to main, off by default, and flipped on from DevTools by anyone who wants to preview the WIP state.

This post walks through the pattern using the toggleCashInstrumentUx flag that gates the USDC-as-instrument work (PR #601) as a worked example.

The two kinds of flag we use#

ymax has two flag systems, and they do different jobs:

  1. PostHog flags — server-driven, per-user, used for staged rollouts and experiments. Example: show-optimization-suggestions, show-feedback. Flipping one of these affects real users.
  2. Console flags — client-only, persisted in localStorage, used for WIP features that are not ready for users at all. Only developers and designers who know the console command can turn them on.

This post is about the second kind. The question it answers is: how do I land a big, user-visible UX change behind a flag without it being a PostHog decision yet?

The shape of a console flag#

Everything lives in one file: a Zustand store + a window.toggleXxx() helper. Here is the full store for the cash-instrument flag:

// ui/src/stores/useCashInstrumentUxStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const STORAGE_KEY = 'ymax:cash-instrument-ux';

declare global {
  interface Window {
    toggleCashInstrumentUx?: (enabled?: boolean) => boolean;
    __YMAX_CASH_INSTRUMENT_UX_BANNER_SHOWN__?: boolean;
  }
}

type CashInstrumentUxStore = {
  enabled: boolean;
  setEnabled: (enabled: boolean) => void;
  toggle: () => boolean;
};

export const useCashInstrumentUxStore = create<CashInstrumentUxStore>()(
  persist(
    set => ({
      enabled: false,
      setEnabled: enabled => set({ enabled }),
      toggle: () => {
        let next = false;
        set(state => {
          next = !state.enabled;
          return { enabled: next };
        });
        return next;
      },
    }),
    { name: STORAGE_KEY, version: 0 },
  ),
);

Three things earn their keep here:

  • persist middleware writes enabled to localStorage under a namespaced key (ymax:cash-instrument-ux). The flag survives reloads, which matters — otherwise the first person who tried the WIP UX would have to re-flip it every time the page reloaded.
  • version: 0 is cheap insurance. If the flag ever grows into a richer shape (multiple sub-toggles, enum instead of boolean), bumping the version lets us migrate cleanly.
  • declare global types the window.toggleCashInstrumentUx function so TypeScript knows about it everywhere, not just inside this file.

The console command#

Persisted state is only half the story. The other half is how a developer turns it on. We attach a function to window so you can call it from DevTools:

export function installCashInstrumentUxConsoleCommand(): void {
  if (typeof window === 'undefined') return;

  window.toggleCashInstrumentUx = (enabled?: boolean) => {
    let next: boolean;
    if (enabled === undefined) {
      next = useCashInstrumentUxStore.getState().toggle();
    } else {
      next = Boolean(enabled);
      useCashInstrumentUxStore.getState().setEnabled(next);
    }
    logCashInstrumentUxState(next);
    return next;
  };

  if (!window.__YMAX_CASH_INSTRUMENT_UX_BANNER_SHOWN__) {
    window.__YMAX_CASH_INSTRUMENT_UX_BANNER_SHOWN__ = true;
    const enabled = readCashInstrumentUxEnabled();
    console.log(
      `[ymax] Cash instrument UX: call toggleCashInstrumentUx() in the console to show or hide. ` +
        `Persisted in localStorage (${STORAGE_KEY}). Currently: ${enabled ? 'ON' : 'OFF'}.`,
    );
  }
}

The function supports three calling conventions:

toggleCashInstrumentUx()       // flip on/off
toggleCashInstrumentUx(true)   // force on
toggleCashInstrumentUx(false)  // force off

The one-time banner is the part I like most. Every time someone opens DevTools on a fresh load, they see a log line telling them the flag exists, what the command is, and what state it is in right now. You do not have to remember the name of the toggle or read the PR description — the app tells you.

The __YMAX_CASH_INSTRUMENT_UX_BANNER_SHOWN__ guard stops the banner from spamming the console if the install function somehow runs twice (HMR, StrictMode double-invoke, etc).

Wiring it up at app start#

The store and the console command are dead weight unless the command gets installed. We do it in App.tsx:

import { installCashInstrumentUxConsoleCommand } from '@/stores/useCashInstrumentUxStore';

const App = () => {
  useEffect(() => {
    installCashInstrumentUxConsoleCommand();
  }, []);
  // ...
};

One useEffect, empty dep array, runs once. That is all.

Reading the flag in components#

Components that care about the flag subscribe with a Zustand selector:

// ui/src/components/StrategyDashboard.tsx
import { useCashInstrumentUxStore } from '@/stores/useCashInstrumentUxStore';

const showCashInstruments = useCashInstrumentUxStore(s => s.enabled);

The selector form (s => s.enabled) matters: it means the component only re-renders when enabled flips, not on any other store change. Cheap to sprinkle around.

For code that runs outside React (hooks called imperatively, plain functions), there is a non-reactive escape hatch:

export function readCashInstrumentUxEnabled(): boolean {
  return useCashInstrumentUxStore.getState().enabled;
}

Same source of truth, no subscription. Use it sparingly — if the surrounding code is rendered, a selector is almost always what you want because it reacts when the flag is toggled live.

How the flag actually gates UI#

The usage pattern is deliberately boring: a branch on the boolean. Here is the shape it takes across ~10 files in PR #601:

const showCashInstruments = useCashInstrumentUxStore(s => s.enabled);

// Hide the new thing by default
if (!showCashInstruments) {
  return <LegacyUnallocatedRow ... />;
}

// Show the new thing when the flag is on
return <CashInstrumentRow ... />;

Or in the opportunity list, to hide cash instruments from the default view but show them when the flag is on:

const opportunities = allOpportunities.filter(op => {
  if (isCashInstrument(op) && !showCashInstruments) return false;
  return true;
});

The point is: the legacy path stays intact. The flag is additive. Turning it off should be indistinguishable from main before the PR landed. This is what makes it safe to merge a huge feature PR (~700 lines across 23 files, in this case) without coordinating a cutover.

Why not just use PostHog?#

PostHog is great for controlled rollouts to real users, but it is overkill for WIP work:

  • Network dependency. PostHog flags load asynchronously. isFeatureEnabled can return undefined on first render. For a WIP flag that only the dev toggling it cares about, this adds flicker and edge cases for no benefit.
  • Auth coupling. PostHog identifies users. WIP flags should not require you to be logged in as a specific person to preview the UX.
  • Blast radius. A misconfigured PostHog flag can ship to users. A console flag literally cannot — there is no UI to turn it on.
  • Review signal. Seeing toggleCashInstrumentUx in a PR tells a reviewer, at a glance, “this is WIP, gated behind a dev-only switch.” That intent is harder to read off a PostHog key.

When the feature is ready for real users, the migration is usually a small PR: replace the Zustand selector with a PostHog check (or just delete the flag and commit to the new UX).

The rollout ergonomics this gives you#

The workflow this pattern unlocks is what actually makes it worth the ~80 lines of store code:

  1. Small PRs land incrementally — rollout plumbing, then opportunity-surface updates, then portfolio behavior, then fixup commits. Each one is reviewable in isolation and mergeable to main.
  2. Designers can self-serve. “Open DevTools, paste toggleCashInstrumentUx(true), reload.” No deploy, no staging environment, no dev sitting next to them.
  3. Bug reports come with state. When someone reports a regression, you ask: “did you have the flag on?” The persisted state means they can check.
  4. Deletion is a grep. When the feature ships, removing the flag is literally: delete the store file, remove the installCashInstrumentUxConsoleCommand() call, grep for the selector name, delete every if (!showCashInstruments) branch. No database migration, no PostHog cleanup.

The checklist for adding one#

If you want to add a console flag to a ymax-style app:

  1. Create useYourFlagStore.ts with persist middleware and a namespaced localStorage key (appname:flag-name).
  2. Export a install...ConsoleCommand() that attaches window.toggleYourFlag and logs a one-time banner.
  3. Call the installer from a top-level useEffect in App.tsx.
  4. In components, subscribe with a selector: useYourFlagStore(s => s.enabled).
  5. In non-React code, use a readYourFlagEnabled() helper that calls .getState().
  6. Keep the legacy path working. The flag should be additive until you are ready to delete it.

That is the whole pattern. Persist, expose, subscribe, delete.

UI Feature Flags — How We Ship WIP UX in ymax
https://rabisiddique.com/posts/ui-feature-flags-ymax/
Author
Rabi Siddique
Published at
2026-04-21