Skip to content

sf-en/latch

Repository files navigation

Latch

Latch is a performance-first, backend-agnostic feature flag SDK for React and enterprise microfrontends. It gives hosts and remotes one small client that can bootstrap from server-rendered flags, normalize any remote flag API, and notify only the UI that actually subscribed to changed flags.

This repo is a pnpm + Turborepo TypeScript monorepo.

This source/demo alpha is intended to be cloned and run from the workspace. Package install commands in the package READMEs are future guidance for when the @latchflags/* packages are published; this repo does not include an npm publishing workflow.

Packages

  • @latchflags/core: framework-agnostic flag client with zero runtime dependencies.
  • @latchflags/contract: optional backend-agnostic JSON contract helpers for typed flag definitions, defaults, validation, fixtures, and normalization.
  • @latchflags/react: React context and useSyncExternalStore bindings for fine-grained subscriptions.
  • @latchflags/devtools: development-only React flag inspector for local overrides and source debugging.
  • @latchflags/tanstack-query: optional TanStack Query adapter for teams that want React Query caching, retry, refetch, and stale data behavior while Latch owns flag state.
  • @latchflags/cli: optional local-first command line checks for validating fixtures, auditing source usage, generating docs, and printing reports.
  • @latchflags/playground: Vite React playground using a mocked enterprise flag gateway response.
  • @latchflags/mf-host, @latchflags/mf-remote-dashboard, and @latchflags/mf-remote-settings: Vite module federation demo apps for a host-owned shared Latch client.

Local Setup

Prerequisites:

  • Node.js that can run the locked toolchain in package.json.
  • pnpm 11.6.0. The repo records this in packageManager; Corepack can use it directly when it is enabled in your Node install.

From a fresh clone:

pnpm install
pnpm build
pnpm test
pnpm typecheck
pnpm lint
pnpm dev
pnpm dev:mf

What those commands cover:

  • pnpm install: installs the workspace from pnpm-lock.yaml.
  • pnpm build: builds package ESM/CJS/type outputs and demo apps through Turborepo.
  • pnpm test: runs package tests, including security, subscription, package boundary, React, devtools, and TanStack Query adapter coverage.
  • pnpm typecheck: runs TypeScript checks.
  • pnpm lint: currently runs TypeScript-backed lint checks.
  • pnpm dev: starts the playground on http://127.0.0.1:5173.
  • pnpm dev:mf: builds and previews the module federation host and remotes.

The workspace uses pnpm 11 settings in pnpm-workspace.yaml. Dependency build scripts are not allowed by default; esbuild is explicitly recorded as an ignored build dependency. No environment variables are required for local development, and there is intentionally no .env.example because the playground and module federation demos use local mock data only.

Clone This and Make It Yours

Latch is meant to be easy to fork for an internal flag SDK or a demo baseline:

  • Rename packages by changing the name fields in packages/*/package.json and updating workspace imports that start with @latchflags/.
  • Rename demo apps by changing apps/*/package.json names and any matching Turborepo filters in root scripts such as dev or dev:mf.
  • Change default and bootstrap flags in apps/playground/src/mockBackend.ts and apps/mf-host/src/latchClient.ts.
  • Adapt mocked remote responses in apps/playground/src/mockBackend.ts and apps/mf-host/src/mockFlagGateway.ts; both normalize into the same flat Record<string, boolean> shape used by core.
  • Replace examples' fetch("/api/flags") snippets with your own service only in your application code. The checked-in demos do not call private services.
  • Keep optional integrations optional: apps can import @latchflags/contract, @latchflags/devtools, or @latchflags/tanstack-query, but @latchflags/core and @latchflags/react should not depend on them.

Contract Usage

Use @latchflags/contract when frontend and backend teams want a small shared wire shape without adding a backend SDK:

{
  "flags": {
    "dashboardV2": true,
    "betaSearch": false
  },
  "meta": {
    "environment": "dev",
    "version": "2026.06.04"
  }
}

Only flags is required. Every flag value must be boolean. meta is optional and can carry operational details such as environment or version.

import { defineFlagContract } from "@latchflags/contract";
import { createFlagClient } from "@latchflags/core";

const flagContract = defineFlagContract({
  dashboardV2: {
    default: false,
    description: "Modern dashboard shell.",
    owner: "Growth"
  },
  betaSearch: {
    default: false,
    description: "Search preview.",
    owner: "Discovery"
  }
});

function normalizeOrThrow(response: unknown) {
  const result = flagContract.validate(response);

  if (!result.ok) {
    throw new TypeError(result.errors.map((error) => error.message).join(" "));
  }

  return result.flags;
}

const client = createFlagClient({
  defaults: flagContract.defaults,
  fetcher: () => fetch("/api/flags").then((response) => response.json()),
  normalize: normalizeOrThrow
});

Backends can be Spring Boot, Go, Rails, Node, serverless functions, or anything else that returns the JSON contract. Latch does not require Java, Spring, or backend SDK packages. See packages/contract/README.md for validation, fixture, TanStack Query, and Spring Boot-style endpoint examples.

CLI Usage

@latchflags/cli is optional and local-only. It does not add a hosted service, auth, deployment, npm publishing, dashboards, targeting, rollouts, experiments, or backend SDK requirements. Use it from this workspace when teams want to check whether flags are defined, used, documented, valid, and safe. The repository CI runs it as a local quality gate.

Build the local binary:

pnpm --filter @latchflags/cli build
pnpm latch --help

Validate backend response fixtures:

pnpm latch validate \
  --flags packages/cli/examples/flags.json \
  packages/cli/examples/fixtures/flags.dev.json

Audit source usage:

pnpm latch audit \
  --flags packages/cli/examples/flags.json \
  --src "packages/cli/examples/src/**/*.{ts,tsx}"

Generate Markdown docs:

pnpm latch docs \
  --flags packages/cli/examples/flags.json \
  --out FLAGS.md

Print a combined report:

pnpm latch report \
  --flags packages/cli/examples/flags.json \
  --src "packages/cli/examples/src/**/*.{ts,tsx}" \
  packages/cli/examples/fixtures/flags.dev.json

See packages/cli/README.md for the flag definition shape, scanner patterns, and command details.

Core Usage

import { createFlagClient } from "@latchflags/core";

const client = createFlagClient({
  defaults: {
    billingPortal: false,
    commandCenter: false,
    searchV2: false
  },
  initialFlags: {
    commandCenter: true
  },
  localOverrides: true,
  fetcher: async () => {
    const response = await fetch("/api/flags");
    return response.json();
  },
  normalize(response: {
    assignments: Array<{ key: string; enabled: boolean }>;
  }) {
    return Object.fromEntries(
      response.assignments.map((flag) => [flag.key, flag.enabled])
    );
  }
});

client.isEnabled("commandCenter"); // true on first render from initialFlags
await client.load();
client.getAll();

initialFlags is the bootstrap path: render the app with known flags before the remote request finishes. Remote failures do not throw through consumers; the client keeps the current/default snapshot and exposes getStatus() and getError().

Local Overrides

Local overrides are developer-controlled values that take priority over every other source:

override > remote > initialFlags > defaults > false

Enable browser persistence with localOverrides: true. The core package only touches globalThis.localStorage when that option is enabled, and storage can be injected for tests, server runtimes, or custom persistence:

const client = createFlagClient({
  defaults: {
    billingPortal: false,
    searchV2: false
  },
  localOverrides: {
    storage: window.localStorage,
    storageKey: "acme:latch-overrides"
  }
});

client.setOverride("billingPortal", true);
client.getFlagSource("billingPortal"); // "override"
client.getOverrides(); // { billingPortal: true }
client.clearOverride("billingPortal");
client.clearOverrides();

getAllFlagDetails() returns the inspection data used by devtools: current value, source, default value, remote value, initial value, and override value for each known flag.

React Usage

import {
  Feature,
  FeatureFlagProvider,
  useFlag,
  useFlags
} from "@latchflags/react";
import { client } from "./flags";

function Shell() {
  return (
    <FeatureFlagProvider client={client}>
      <Nav />
      <Feature flag="auditLog" fallback={<span>Audit hidden</span>}>
        <AuditLog />
      </Feature>
    </FeatureFlagProvider>
  );
}

function Nav() {
  const commandCenter = useFlag("commandCenter");
  const flags = useFlags(["billingPortal", "searchV2"] as const);

  return (
    <nav>
      {commandCenter ? <a href="/command">Command</a> : null}
      {flags.billingPortal ? <a href="/billing">Billing</a> : null}
      {flags.searchV2 ? <a href="/search">Search</a> : null}
    </nav>
  );
}

useFlag("a") subscribes only to a. useFlags(["a", "b"]) subscribes only to a and b. Unrelated flag changes do not re-render those components.

TanStack Query Usage

Install @latchflags/tanstack-query only in apps that already use TanStack Query or want it to own flag fetching behavior:

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineFlagContract } from "@latchflags/contract";
import { createFlagClient } from "@latchflags/core";
import { FeatureFlagProvider, useFlag } from "@latchflags/react";
import { useFlagQuery } from "@latchflags/tanstack-query";

const queryClient = new QueryClient();
const flagContract = defineFlagContract({
  billingPortal: { default: false, owner: "Revenue" },
  searchV2: { default: false, owner: "Discovery" }
});
const client = createFlagClient({
  defaults: flagContract.defaults,
  initialFlags: window.__BOOTSTRAP_FLAGS__,
  localOverrides: true
});

function normalizeOrThrow(response: unknown) {
  const result = flagContract.validate(response);

  if (!result.ok) {
    throw new TypeError(result.errors.map((error) => error.message).join(" "));
  }

  return result.flags;
}

function FlagHydration({ userId }: { userId: string }) {
  useFlagQuery(client, {
    queryKey: ["flags", userId],
    queryFn: () => fetch(`/api/flags?user=${userId}`).then((response) => response.json()),
    normalize: normalizeOrThrow,
    staleTime: 60_000,
    retry: 2,
    enabled: Boolean(userId),
    refetchOnWindowFocus: false,
    refetchInterval: 300_000
  });

  return null;
}

function SearchLink() {
  return useFlag("searchV2") ? <a href="/search">Search</a> : null;
}

export function App({ userId }: { userId: string }) {
  return (
    <QueryClientProvider client={queryClient}>
      <FeatureFlagProvider client={client}>
        <FlagHydration userId={userId} />
        <SearchLink />
      </FeatureFlagProvider>
    </QueryClientProvider>
  );
}

TanStack Query owns fetching, caching, retry, refetch, staleTime, enabled, and window-focus behavior. Latch owns defaults, initialFlags, remote flag hydration, overrides, status, errors, sources, and subscriptions. Query failures preserve the current/default flags and report the error to the Latch client. Successful queries hydrate remote flags and clear the previous remote error. Malformed normalized query data preserves the current/default flags and reports an error to the Latch client. If normalize throws, for example after contract validation fails, the adapter records that thrown error on the Latch client instead of throwing through React consumers.

Use normalize for arbitrary backend envelopes:

useFlagQuery(client, {
  queryKey: ["flags", tenantId],
  queryFn: fetchTenantFlags,
  normalize(response: {
    assignments: Array<{ key: string; enabled: boolean }>;
  }) {
    return Object.fromEntries(
      response.assignments.map((assignment) => [
        assignment.key,
        assignment.enabled
      ])
    );
  }
});

Local overrides still win over fetched data:

override > remote > initialFlags > defaults > false

Use createFlagQueryOptions() when you want reusable TanStack options for useFlagQuery() and queryClient.prefetchQuery().

DevTools Usage

Install @latchflags/devtools only where you want local inspection controls. Render it behind a development guard so it is not shown in production UI:

import { LatchDevTools } from "@latchflags/devtools";
import { flagClient } from "./flags";

export function AppTools() {
  if (import.meta.env.PROD) {
    return null;
  }

  return <LatchDevTools client={flagClient} />;
}

The panel lists all known flags, their current value and source, layer values, client status, and fetch error. Boolean flags can be overridden directly, and one or all overrides can be cleared. Devtools works against the same client instance used by the app; configure localOverrides on that core client when you want override persistence.

Security Model

Latch treats all flag inputs as untrusted unless they are set by your own application code:

  • @latchflags/contract validates the backend wire contract before hydration and reports structured errors without throwing by default.
  • Effective flag state is boolean-only. defaults, initialFlags, remote flags, normalized output, stored overrides, and constructor overrides ignore non-boolean values.
  • client.setOverride(key, value) is a developer API and throws unless value is boolean.
  • Prototype-pollution keys are not accepted. External flag objects ignore __proto__, constructor, and prototype; setOverride() throws for those keys.
  • normalize should return a non-array object whose values are booleans. In core-owned load()/reload(), malformed normalized output is treated as a bad response: the current usable state is preserved, getStatus() becomes "error", and getError() explains the problem.
  • The TanStack Query adapter applies the same malformed-normalize guard before hydrating the core client. Thrown normalizer errors are also captured as Latch client errors, which lets contract validation errors show up in getError() and DevTools without crashing consumers.
  • Failed fetches and bad remote responses do not throw through React consumers. They preserve the current/default snapshot and surface status/error metadata through the core client and devtools.
  • Local override persistence is opt-in, namespaced with storageKey, injectable through localOverrides: { storage, storageKey }, and safe when window/localStorage is unavailable.
  • The packages do not use eval, dynamic code execution, or hidden browser globals. Browser APIs are limited to app entry points, docs examples, and the guarded optional localStorage path in core.

Performance Model

Latch stores immutable snapshots. getAll() returns the same object reference when the merged flag values have not changed, even if override metadata changes. Remote loads and override writes diff the previous and next state before notifying subscribers:

  • subscribe() listeners run only when at least one effective flag value changes;
  • subscribeToFlag("key") listeners run only when that flag's effective value changes;
  • subscribeToClientState() listeners run when inspection state changes, including sources, overrides, status, and fetch errors;
  • failed fetches keep the current/default flag state;
  • missing boolean flags read as their default, or false when no default exists.

The React package stores only the client in context. Flag updates are delivered through useSyncExternalStore, avoiding broad context-driven re-render storms. Selected snapshots keep stable references when their effective values have not changed.

Playground

The playground demonstrates initialFlags bootstrap data, TanStack Query remote hydration, contract-defined defaults, contract response validation, failed-load state preservation, React useFlag/useFlags usage, and an embedded devtools panel. The playground client enables localOverrides: true, so devtools override changes are applied to the same client used by the app and persist through browser localStorage when available.

Module Federation Demo

The module federation demo in apps/mf-host, apps/mf-remote-dashboard, and apps/mf-remote-settings shows one host-created Latch client shared across federated React remotes. The host owns contract-defined defaults, initialFlags, mocked remote hydration, and DevTools. Remotes import the exposed host client, use @latchflags/react, and avoid duplicate clients, contract setup, or flag fetching.

See docs/module-federation.md for the run command, app ports, and singleton pattern details.

Module Federation Singleton

Create the client once in the host, share the packages as singletons, and pass the host-created client into remotes.

// host/src/flags.ts
import { defineFlagContract } from "@latchflags/contract";
import { createFlagClient } from "@latchflags/core";

const flagContract = defineFlagContract({
  commandCenter: { default: false }
});

export const flagClient = createFlagClient({
  defaults: flagContract.defaults,
  initialFlags: window.__BOOTSTRAP_FLAGS__,
  fetcher: () => fetch("/api/flags").then((response) => response.json()),
  normalize: (response) => response.flags
});
// webpack module federation shape
shared: {
  react: { singleton: true },
  "react-dom": { singleton: true },
  "@latchflags/core": { singleton: true },
  "@latchflags/react": { singleton: true },
  "@latchflags/tanstack-query": { singleton: true }
}
// remote entry point
import { FeatureFlagProvider } from "@latchflags/react";

export function RemoteApp({ flagClient }) {
  return (
    <FeatureFlagProvider client={flagClient}>
      <RemoteRoutes />
    </FeatureFlagProvider>
  );
}

This keeps one flag snapshot and one subscription graph across the host and all module-federated remotes.

Troubleshooting

  • Cannot find module '@latchflags/core' during a direct package typecheck: build dependency packages first, or run the root pnpm typecheck so Turbo builds package dependencies in order.
  • pnpm dev:mf cannot load a remote: make sure all three preview processes are running and open the host at http://127.0.0.1:5173.
  • Port 5173 is already in use: stop the other Vite process or run the package script directly with a different Vite port.
  • ERR_PNPM_ABORTED_REMOVE_MODULES_DIR_NO_TTY after changing workspace dependencies in automation: rerun install non-interactively, for example CI=true pnpm install --no-frozen-lockfile, so pnpm can relink the workspace and update pnpm-lock.yaml.
  • DevTools overrides are not persisting: enable localOverrides: true or inject localOverrides: { storage, storageKey } on the same core client passed to React and devtools.
  • A flag is unexpectedly false: check getAllFlagDetails() for its source. Missing flags fall back to defaults and then false; non-boolean or unsafe remote values are ignored.

Non-goals

Latch does not include a backend server, Java SDK, backend SDK, hosted dashboard, targeting rules, percentage rollout engine, experiment platform, deploy automation, or npm publishing workflow. Bring your own flag API and normalize it into Latch's flat boolean flag shape or the @latchflags/contract JSON response. The checked-in CI is limited to source quality gates.

About

Backend-agnostic feature flags for React apps and microfrontends

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages