Skip to main content

Command Palette

Search for a command to run...

How to Add Feature Flags to React in 5 Minutes

Updated
9 min read

Every React application reaches a point where you need to control what users see — without redeploying. Maybe you want to test a new checkout flow with 10% of users. Maybe you need a kill switch for a feature that's causing performance issues. Maybe your PM wants to launch a feature on Tuesday at 9am, not whenever the deploy pipeline finishes.

Feature flags solve all of these problems. They let you wrap components, routes, or entire features behind a toggle that you control from a dashboard — no code changes, no redeployment, no downtime.

If you've ever used an environment variable like NEXT_PUBLIC_ENABLE_NEW_UI and redeployed just to flip it, you already understand the concept. Feature flags are the same idea, but dynamic: you change the value in a dashboard, and every connected client picks it up in real time.

In this guide, we'll go from zero to production-ready React feature flags in 5 minutes.

The Simplest Approach: Environment Variables

Before reaching for a library, many teams start with environment variables:

// feature-flags.ts
export const flags = {
  newPricing: process.env.NEXT_PUBLIC_NEW_PRICING === 'true',
  darkMode: process.env.NEXT_PUBLIC_DARK_MODE === 'true',
};
// PricingPage.tsx
import { flags } from './feature-flags';

export function PricingPage() {
  if (flags.newPricing) {
    return <NewPricingTable />;
  }
  return <LegacyPricingTable />;
}

This works for simple cases, but has serious limitations:

  • Requires a redeploy to change any flag value

  • No gradual rollouts — it's all-or-nothing for every user

  • No user targeting — you can't enable a feature for beta testers only

  • No instant kill switch — if a feature breaks, you need to redeploy to disable it

  • No audit trail — who changed what, when?

For anything beyond the simplest use case, you need a feature flag service. Let's set one up.

Quick Start: React Feature Flags in 5 Minutes

We'll use Rollgate, which provides a React-native API with hooks and providers. The entire setup takes three steps.

Step 1: Install the SDK

npm install @rollgate/sdk-react

Step 2: Wrap Your App with the Provider

// app.tsx
import { RollgateProvider } from '@rollgate/sdk-react';

export default function App({ children }: { children: React.ReactNode }) {
  const currentUser = useCurrentUser(); // your auth hook

  return (
    <RollgateProvider
      apiKey="YOUR_API_KEY"
      user={{ id: currentUser.id, email: currentUser.email, plan: currentUser.plan }}
    >
      {children}
    </RollgateProvider>
  );
}

The provider connects to Rollgate's API, fetches your flag configuration, and makes it available to every component via React context. It also opens an SSE connection for real-time updates — when you toggle a flag in the dashboard, every connected client picks it up within seconds.

The user object is optional but recommended. It enables targeting rules (e.g., "enable for users on the Pro plan") and consistent rollout assignments.

Step 3: Use the useFlag Hook

import { useFlag } from '@rollgate/sdk-react';

export function CheckoutPage() {
  const showNewCheckout = useFlag('new-checkout', false);

  if (showNewCheckout) {
    return <NewCheckout />;
  }
  return <LegacyCheckout />;
}

That's it. Three steps, five minutes.

The useFlag Hook in Depth

The useFlag hook accepts two arguments: the flag key and a default value.

Boolean Flags

The most common type:

const isEnabled = useFlag('dark-mode', false);

String Flags

For A/B testing and multivariate experiments:

const variant = useFlag('checkout-layout', 'control');
// 'control' | 'single-page' | 'multi-step'

Number Flags

For numeric configuration:

const maxItems = useFlag('cart-max-items', 10);

JSON Flags

For complex configuration objects:

const config = useFlag('pricing-config', { currency: 'USD', showAnnual: true });

Default Values Matter

The default value serves two purposes:

  1. Fallback — if the flag doesn't exist or the SDK can't reach the server, your app uses this value instead of crashing

  2. Type inference — TypeScript infers the return type from the default, so useFlag('dark-mode', false) returns boolean

Always choose a safe default. If your flag controls an untested feature, default to false.

Conditional Rendering Patterns

Component-Level Gating

Show or hide an entire component:

function SettingsPage() {
  const showBetaFeatures = useFlag('beta-features', false);

  return (
    <div>
      <GeneralSettings />
      <SecuritySettings />
      {showBetaFeatures && <BetaFeatures />}
    </div>
  );
}

Route-Level Gating

Control access to entire pages:

import { useFlag } from '@rollgate/sdk-react';
import { Navigate } from 'react-router-dom';

function AnalyticsPage() {
  const analyticsEnabled = useFlag('analytics-page', false);

  if (!analyticsEnabled) {
    return <Navigate to="/dashboard" replace />;
  }

  return <AnalyticsDashboard />;
}

Wrapper Component Pattern

For reusable gating, create a wrapper:

function FeatureGate({
  flag,
  fallback = null,
  children,
}: {
  flag: string;
  fallback?: React.ReactNode;
  children: React.ReactNode;
}) {
  const isEnabled = useFlag(flag, false);
  return isEnabled ? <>{children}</> : <>{fallback}</>;
}

// Usage
<FeatureGate flag="new-header" fallback={<OldHeader />}>
  <NewHeader />
</FeatureGate>

Gradual Rollouts

Gradual rollouts let you release a feature to a percentage of users instead of everyone at once. This is the primary reason most teams adopt feature flags.

How It Works

When you create a flag with a percentage rollout (e.g., 20%), the SDK uses consistent hashing to determine whether a specific user sees the feature:

  • The same user always gets the same result for a given flag (no flickering)

  • Different flags hash independently

  • The distribution is uniform

In your code, nothing changes:

function CheckoutPage() {
  // Returns true for 20% of users, false for the rest
  const useNewCheckout = useFlag('new-checkout', false);
  return useNewCheckout ? <NewCheckout /> : <LegacyCheckout />;
}

When you increase the rollout from 20% to 50%, users who were already seeing the feature continue to see it, and new users are added to the cohort.

Why User IDs Matter

For consistent hashing to work, the SDK needs a stable user identifier:

<RollgateProvider
  apiKey="YOUR_API_KEY"
  user={{ id: user.id }}
>
  {children}
</RollgateProvider>

Without a user ID, the SDK generates a random identifier per session, which means the same person might see different experiences across sessions.

A/B Testing

A/B testing takes gradual rollouts further. Instead of on/off, you assign users to named variants and measure which performs better.

Create a flag with string variants: control (50%), variant-a (25%), variant-b (25%).

function PricingPage() {
  const variant = useFlag('pricing-experiment', 'control');

  switch (variant) {
    case 'variant-a':
      return <PricingAnnualFirst />;
    case 'variant-b':
      return <PricingCompact />;
    default:
      return <PricingDefault />;
  }
}

Tracking Conversions

Track events when users convert:

import { useFlag, useRollgate } from '@rollgate/sdk-react';

function PricingPage() {
  const variant = useFlag('pricing-experiment', 'control');
  const { track } = useRollgate();

  function handleSubscribe(userId: string) {
    track({
      flagKey: 'pricing-experiment',
      eventName: 'subscription-started',
      userId,
      variationId: variant,
    });
  }

  return <PricingTable variant={variant} onSubscribe={handleSubscribe} />;
}

Feature Flags with Next.js

Next.js adds complexity with Server Components, SSR, and middleware.

Client Components

Identical to standard React — wrap your layout with the provider and use hooks.

Server Components

Use the Node.js SDK for server-side evaluation:

// app/dashboard/page.tsx (Server Component)
import { createClient } from '@rollgate/sdk-node';

const rollgate = createClient({ serverKey: process.env.ROLLGATE_SERVER_KEY! });

export default async function DashboardPage() {
  const showNewDashboard = await rollgate.isEnabled('new-dashboard', {
    user: { id: getCurrentUserId() },
  });

  return showNewDashboard ? <NewDashboard /> : <LegacyDashboard />;
}

Middleware

For route-level gating before rendering:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@rollgate/sdk-node';

const rollgate = createClient({ serverKey: process.env.ROLLGATE_SERVER_KEY! });

export async function middleware(request: NextRequest) {
  const userId = request.cookies.get('user_id')?.value;
  const betaEnabled = await rollgate.isEnabled('beta-access', {
    user: { id: userId },
  });

  if (request.nextUrl.pathname.startsWith('/beta') && !betaEnabled) {
    return NextResponse.redirect(new URL('/dashboard', request.url));
  }
}

Best Practices

1. Keep Flag Checks Out of Hot Render Paths

// Bad — hook inside a callback violates Rules of Hooks
function ProductList({ products }) {
  return products.map((p) => {
    const showBadge = useFlag('new-badge', false); // breaks Rules of Hooks
    return <ProductCard key={p.id} showBadge={showBadge} product={p} />;
  });
}

// Good — evaluate once at the top level, pass as prop
function ProductList({ products }) {
  const showBadge = useFlag('new-badge', false); // called once, at top level
  return products.map((p) => (
    <ProductCard key={p.id} showBadge={showBadge} product={p} />
  ));
}

2. Test Both Flag Paths

Every feature flag creates two code paths. Test both by mocking the SDK:

import { useFlag } from '@rollgate/sdk-react';

jest.mock('@rollgate/sdk-react', () => ({
  ...jest.requireActual('@rollgate/sdk-react'),
  useFlag: jest.fn(),
}));

describe('CheckoutPage', () => {
  it('renders new checkout when flag is on', () => {
    (useFlag as jest.Mock).mockReturnValue(true);
    render(<CheckoutPage />);
    expect(screen.getByText('New Checkout')).toBeInTheDocument();
  });

  it('renders legacy checkout when flag is off', () => {
    (useFlag as jest.Mock).mockReturnValue(false);
    render(<CheckoutPage />);
    expect(screen.getByText('Legacy Checkout')).toBeInTheDocument();
  });
});

Mocking useFlag directly lets you control flag values in tests without hitting the network.

3. Clean Up Stale Flags

Feature flags are temporary. Once a feature is fully rolled out, remove the flag and the conditional logic. Stale flags create confusion and increase code complexity. Set a review date when you create each flag.

4. Name Flags Descriptively

Use kebab-case and be specific:

  • Good: checkout-single-page, pricing-annual-toggle, onboarding-v2

  • Bad: flag1, test, new-feature, temp

FAQ

How does it affect bundle size? The @rollgate/sdk-react package is under 5 KB gzipped. No impact on build time, and tree-shaking removes unused exports.

Do feature flags cause a flash of content? By default, the SDK renders with default values until the configuration is fetched. To prevent this, use the isReady check from useRollgate() to show a loading state, or use server-side evaluation with the Node SDK in Next.js.

What happens if the API is unavailable? The SDK uses local caching and a circuit breaker. If the API is unreachable, it falls back to the last known values. Your app continues working with the most recent configuration.

Are evaluations counted per render? No. Flag evaluation happens when the SDK fetches the configuration, not on each useFlag call. The hook reads from an in-memory cache — essentially free.

Get Started

  1. Install @rollgate/sdk-react

  2. Wrap your app with RollgateProvider

  3. Use useFlag to read flag values

  4. Control rollouts from the dashboard

Rollgate has a free tier with 500K API requests/month, unlimited flags, and all 13 SDKs included. Sign up at rollgate.io — takes 30 seconds, no credit card required.

More from this blog

R

Rollgate Blog

12 posts