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.
@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 anduseSyncExternalStorebindings 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.
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:mfWhat those commands cover:
pnpm install: installs the workspace frompnpm-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 onhttp://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.
Latch is meant to be easy to fork for an internal flag SDK or a demo baseline:
- Rename packages by changing the
namefields inpackages/*/package.jsonand updating workspace imports that start with@latchflags/. - Rename demo apps by changing
apps/*/package.jsonnames and any matching Turborepo filters in root scripts such asdevordev:mf. - Change default and bootstrap flags in
apps/playground/src/mockBackend.tsandapps/mf-host/src/latchClient.ts. - Adapt mocked remote responses in
apps/playground/src/mockBackend.tsandapps/mf-host/src/mockFlagGateway.ts; both normalize into the same flatRecord<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/coreand@latchflags/reactshould not depend on them.
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.
@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 --helpValidate backend response fixtures:
pnpm latch validate \
--flags packages/cli/examples/flags.json \
packages/cli/examples/fixtures/flags.dev.jsonAudit 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.mdPrint 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.jsonSee packages/cli/README.md for the flag definition
shape, scanner patterns, and command details.
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 are developer-controlled values that take priority over every other source:
override > remote > initialFlags > defaults > falseEnable 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.
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.
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 > falseUse createFlagQueryOptions() when you want reusable TanStack options for
useFlagQuery() and queryClient.prefetchQuery().
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.
Latch treats all flag inputs as untrusted unless they are set by your own application code:
@latchflags/contractvalidates 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 constructoroverridesignore non-boolean values. client.setOverride(key, value)is a developer API and throws unlessvalueis boolean.- Prototype-pollution keys are not accepted. External flag objects ignore
__proto__,constructor, andprototype;setOverride()throws for those keys. normalizeshould return a non-array object whose values are booleans. In core-ownedload()/reload(), malformed normalized output is treated as a bad response: the current usable state is preserved,getStatus()becomes"error", andgetError()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 throughlocalOverrides: { storage, storageKey }, and safe whenwindow/localStorageis 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.
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
falsewhen 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.
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.
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.
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.
Cannot find module '@latchflags/core'during a direct package typecheck: build dependency packages first, or run the rootpnpm typecheckso Turbo builds package dependencies in order.pnpm dev:mfcannot load a remote: make sure all three preview processes are running and open the host athttp://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_TTYafter changing workspace dependencies in automation: rerun install non-interactively, for exampleCI=true pnpm install --no-frozen-lockfile, so pnpm can relink the workspace and updatepnpm-lock.yaml.- DevTools overrides are not persisting: enable
localOverrides: trueor injectlocalOverrides: { storage, storageKey }on the same core client passed to React and devtools. - A flag is unexpectedly
false: checkgetAllFlagDetails()for its source. Missing flags fall back to defaults and thenfalse; non-boolean or unsafe remote values are ignored.
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.