DEV Community

Dmitry Tilyupo
Dmitry Tilyupo

Posted on

Dynamic Configuration in React — Feature Flags Without the Jank

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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} />;
}
Enter fullscreen mode Exit fullscreen mode

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();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

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 />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Override rules are defined in the Replane dashboard:

  • If plan equals premium → return 10000
  • If country equals DE → return 500
  • 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>();
Enter fullscreen mode Exit fullscreen mode
// 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 } }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }}
>
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

4. Ignoring error boundaries

import { ErrorBoundary } from "react-error-boundary";

<ErrorBoundary fallback={<div>Config failed to load</div>}>
  <ReplaneProvider connection={connection}>
    <App />
  </ReplaneProvider>
</ErrorBoundary>
Enter fullscreen mode Exit fullscreen mode

Connection failures throw. Catch them.

Getting Started

npm install @replanejs/react
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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)