We shipped a new checkout flow last week. Product wanted a kill switch. The "solution"? A REACT_APP_NEW_CHECKOUT env var, hardcoded at build time.
Then they asked to roll it out to 10% of users first. Then premium users only. Then specific accounts for beta testing.
That env var wasn't going to cut it.
The Problem With Feature Flags in React
Most React apps handle configuration one of three ways:
1. Build-time env vars
const isNewCheckout = process.env.REACT_APP_NEW_CHECKOUT === "true";
function Checkout() {
return isNewCheckout ? <NewCheckout /> : <LegacyCheckout />;
}
Change it? Rebuild and redeploy the entire app.
2. Props drilled from the top
function App() {
const [flags, setFlags] = useState(null);
useEffect(() => {
fetch("/api/flags")
.then((r) => r.json())
.then(setFlags);
}, []);
if (!flags) return <Loading />;
return <Checkout newCheckoutEnabled={flags.newCheckout} />;
}
Works, but now you're threading props through 15 components. Or you reach for Context, but then every flag change re-renders everything.
3. Third-party SDK with its own patterns
import { useFlags } from "some-feature-flag-sdk";
function Checkout() {
const { newCheckout } = useFlags();
// ...
}
Better, but now you have a $300/month bill and a vendor-specific API.
What Dynamic Configuration Should Feel Like
Here's what I wanted:
function Checkout() {
const isNewCheckout = useConfig<boolean>("new-checkout");
return isNewCheckout ? <NewCheckout /> : <LegacyCheckout />;
}
And when someone flips that value in a dashboard:
- Component re-renders automatically
- No page refresh
- No prop drilling
- Full TypeScript support
Building It With Replane
Replane is open-source (MIT) and does exactly this. Here's the setup:
1. Wrap your app
import { ReplaneProvider } from "@replanejs/react";
function App() {
return (
<ReplaneProvider
connection={{
baseUrl: "https://cloud.replane.dev",
sdkKey: process.env.REACT_APP_REPLANE_SDK_KEY!,
}}
loader={<AppSkeleton />}
>
<Router />
</ReplaneProvider>
);
}
The provider connects via Server-Sent Events. Config loads once, then streams updates in real-time.
2. Read configs anywhere
import { useConfig } from "@replanejs/react";
function Checkout() {
const isNewCheckout = useConfig<boolean>("new-checkout");
const discountBanner = useConfig<string>("checkout-banner-text");
return (
<div>
{discountBanner && <Banner text={discountBanner} />}
{isNewCheckout ? <NewCheckout /> : <LegacyCheckout />}
</div>
);
}
The hook subscribes to that specific config. When it changes on the server, only components using that config re-render.
3. Add targeting with context
Want different values for different users?
function Checkout() {
const { user } = useAuth();
const rateLimit = useConfig<number>("api-rate-limit", {
context: {
userId: user.id,
plan: user.subscription,
country: user.country,
},
});
// Premium users might get 10000, free users get 100
}
Override rules are defined in the Replane dashboard:
- If
planequalspremium→ return10000 - If
countryequalsDE→ return500 - Default →
100
No code changes when you add new rules.
Making It Type-Safe
Generic hooks work, but you can do better:
// config.ts
import { createConfigHook } from "@replanejs/react";
interface AppConfigs {
"new-checkout": boolean;
"checkout-banner-text": string | null;
"api-rate-limit": number;
"pricing-tiers": {
free: { requests: number };
pro: { requests: number };
};
}
export const useAppConfig = createConfigHook<AppConfigs>();
// Checkout.tsx
import { useAppConfig } from "./config";
function Checkout() {
// Autocomplete works, type is inferred
const isNewCheckout = useAppConfig("new-checkout");
// ^? boolean
const pricing = useAppConfig("pricing-tiers");
// ^? { free: { requests: number }; pro: { requests: number } }
}
Typo in the config name? TypeScript catches it. Wrong type assumption? TypeScript catches it.
Handling Loading States
Three options depending on your needs:
Option 1: Loader prop (default)
<ReplaneProvider
connection={connection}
loader={<FullPageSpinner />}
>
<App />
</ReplaneProvider>
Shows loader until all configs are loaded. Simple, but blocks the whole app.
Option 2: Suspense
<Suspense fallback={<FullPageSpinner />}>
<ReplaneProvider connection={connection} suspense>
<App />
</ReplaneProvider>
</Suspense>
Integrates with React's Suspense. Better if you're already using it for data fetching.
Option 3: Async mode with defaults
<ReplaneProvider
connection={connection}
defaults={{
"new-checkout": false,
"api-rate-limit": 100,
}}
async
>
<App />
</ReplaneProvider>
Renders immediately with defaults. Updates to real values when connection establishes. No loading state, but values might "flip" after initial render.
Server-Side Rendering
For Next.js or any SSR setup, you can hydrate from a server-fetched snapshot:
// On server
import { Replane, getReplaneSnapshot } from "@replanejs/react";
const replane = new Replane();
await replane.connect({ baseUrl: "...", sdkKey: "..." });
const snapshot = replane.getSnapshot();
// Pass to client via props or serialized in HTML
// On client
<ReplaneProvider
connection={connection}
snapshot={snapshot} // Hydrates instantly, no loading state
>
<App />
</ReplaneProvider>
The client hydrates immediately from the snapshot, then connects for real-time updates.
When To Use This
Good fits:
- Feature flags for gradual rollouts
- A/B test variants (simple ones)
- Per-user or per-tenant customization
- UI text that marketing wants to tweak
- Operational limits (rate limits, max items, timeouts)
- Kill switches for incident response
Keep as build-time config:
- API endpoints (don't change at runtime)
- Analytics keys (don't change at runtime)
- Anything that affects build output
Common Mistakes
1. Putting everything in dynamic config
Not every value needs real-time updates. If it doesn't change while the app is running, keep it simple.
2. No defaults
// Bad - crashes if config server is down
const limit = useConfig<number>("rate-limit");
// Good - works even before connection
<ReplaneProvider
connection={connection}
defaults={{ "rate-limit": 100 }}
>
3. Context in the wrong place
// Bad - creates new object every render, breaks memoization
const value = useConfig("limit", { context: { userId: user.id } });
// Better - stable reference
const context = useMemo(() => ({ userId: user.id }), [user.id]);
const value = useConfig("limit", { context });
4. Ignoring error boundaries
import { ErrorBoundary } from "react-error-boundary";
<ErrorBoundary fallback={<div>Config failed to load</div>}>
<ReplaneProvider connection={connection}>
<App />
</ReplaneProvider>
</ErrorBoundary>
Connection failures throw. Catch them.
Getting Started
npm install @replanejs/react
If you're self-hosting Replane, point baseUrl to your instance. Otherwise, cloud.replane.dev has a free tier.
import { ReplaneProvider, useConfig } from "@replanejs/react";
function App() {
return (
<ReplaneProvider
connection={{
baseUrl: "https://cloud.replane.dev",
sdkKey: "your-sdk-key",
}}
defaults={{ "feature-enabled": false }}
loader={<div>Loading...</div>}
>
<Main />
</ReplaneProvider>
);
}
function Main() {
const isEnabled = useConfig<boolean>("feature-enabled");
return <div>Feature is {isEnabled ? "on" : "off"}</div>;
}
That checkout kill switch? It's now a toggle in the dashboard. Product can flip it themselves. The 10% rollout? One rule change, no deploy. And when something breaks at 2am, I disable it from my phone.
Questions? Drop a comment or check out the GitHub repo.
Top comments (0)