Dynamic Configuration in React — Feature Flags Without the Jank
Source: Dev.to
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 ? : ;
}
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 ;
return ;
}
Works, but now you’re threading props through many components. Using Context solves the drilling, but 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
function Checkout() {
const isNewCheckout = useConfig("new-checkout");
return isNewCheckout ? : ;
}
When someone flips that value in a dashboard:
- Component re‑renders automatically
- No page refresh needed
- No prop drilling
- Full TypeScript support
Building It With Replane
Replane is open‑source (MIT) and does exactly this.
1. Wrap your app
import { ReplaneProvider } from "@replanejs/react";
function App() {
return (
}
>
);
}
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("new-checkout");
const discountBanner = useConfig("checkout-banner-text");
return (
{discountBanner && }
{isNewCheckout ? : }
);
}
The hook subscribes only to the requested config, so only components that use a particular key re‑render when it changes.
3. Add targeting with context
function Checkout() {
const { user } = useAuth();
const rateLimit = useConfig("api-rate-limit", {
context: {
userId: user.id,
plan: user.subscription,
country: user.country,
},
});
// Premium users might get 10 000, free users get 100
}
Override rules are defined in the Replane dashboard:
- If
planequalspremium→ return10000 - If
countryequalsDE→ return500 - Default →
100
No code changes are required when you add new rules.
Making It Type‑Safe
Generic hooks work, but you can tighten the contract:
// 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();
// 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 } }
}
A typo in the config name? TypeScript catches it. Wrong type assumption? TypeScript catches it.
Handling Loading States
Choose the strategy that fits your app:
Option 1 – Loader prop (default)
}
>
Shows a loader until all configs are loaded. Simple, but blocks the whole app.
Option 2 – Suspense
}>
Integrates with React’s Suspense. Ideal if you already use it for data fetching.
Option 3 – Async mode with defaults
Renders immediately with the provided defaults. Real values replace them once the connection is established. No loading UI, but values may “flip” after the initial render.
Server‑Side Rendering (SSR) Hydration
// On server
import { Replane, getReplaneSnapshot } from "@replanejs/react";
const replane = new Replane();
await replane.connect({ baseUrl: "...", sdkKey: "..." });
const snapshot = replane.getSnapshot(); // ← server‑fetched snapshot
// Pass `snapshot` to the client via props or serialize it into HTML
// On client
The client hydrates immediately from the snapshot, then connects for real‑time updates.
When to Use This
Good fits
- Feature flags for gradual rollouts
- Simple A/B‑test variants
- 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 static.
2. No defaults
// Bad – crashes if the config server is down
const limit = useConfig("rate-limit");
// Good – works even before the connection is established
{/* ... */}
3. Context in the wrong place
// Bad – creates a new object each 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";
Config failed to load}>
Connection failures throw; catch them with an error boundary.
Getting Started
npm install @replanejs/react
If you’re self‑hosting Replane, point baseUrl to your instance. Otherwise, the free tier is available at cloud.replane.dev.
import { ReplaneProvider, useConfig } from "@replanejs/react";
function App() {
return (
Loading…}
>
);
}
function Main() {
const isEnabled = useConfig("feature-enabled");
return Feature is {isEnabled ? "on" : "off"};
}
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 2 am, I disable it from my phone.
Questions? Drop a comment or check out the GitHub repo.