FrontendAccount
Back to frontend track

Refactoring Series

Feature Flags

Use different feature flags to ease the development or feature release process.

Feature Flags

In the last section, we saw the simplest flags through DevelopmentFlag and useDevelopmentFlag. In this section, we will build the feature flags that you will use a lot more often.

Feature flags help you do a lot:

  • Release features internally first
  • Release features for specific users or clients.

Building the new FeatureThree with a flag

Let us quickly build a dummy feature.

src/components/feature-three.tsx
import { Card, Text } from '@mantine/core';
 
export default function FeatureThree() {
  return (
    <Card shadow="sm" padding="lg" radius="md" withBorder>
      <Text fw={500}>New Feature Three</Text>
 
      <Text size="sm" c="dimmed">
        This is the modern version of Feature Three with advanced tools and and
        layout. But this should be behind a feature flag.
      </Text>
    </Card>
  );
}

We will first render it normally on our home and about pages.

src/pages/home.tsx
import FeatureOne from '@/components/feature-one';
import FeatureThree from '@/components/feature-three';
import FeatureTwo from '@/components/FeatureTwo';
import { Box, Button, Paper, Typography } from '@mui/material';
import { Link } from 'react-router';
 
export default function Home() {
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      <Typography variant="h4" gutterBottom>
        Welcome to the Home Page
      </Typography>
      <Typography variant="body1" paragraph>
        This is a simple home page using Material UI v5 components.
      </Typography>
      <Box mt={2}>
        <FeatureOne />
      </Box>
      <Box mt={2}>
        <FeatureTwo />
      </Box>
      <Box mt={2}>
        <FeatureThree />
      </Box>
      <Box mt={2}>
        <Button component={Link} to="/about">
          Go to About Page
        </Button>
      </Box>
    </Paper>
  );
}
src/pages/about.tsx
import FeatureOne from '@/components/feature-one';
import FeatureThree from '@/components/feature-three';
import FeatureTwo from '@/components/FeatureTwo';
import { Box, Button, Paper, Typography } from '@mui/material';
import { Link } from 'react-router';
 
export default function About() {
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      <Typography variant="h4" gutterBottom>
        Welcome to the About Page
      </Typography>
      <Typography variant="body1" paragraph>
        This is a simple about page using Material UI v5 components.
      </Typography>
      <Box mt={2}>
        <FeatureOne />
      </Box>
      <Box mt={2}>
        <FeatureTwo />
      </Box>
      <Box mt={2}>
        <FeatureThree />
      </Box>
      <Box mt={2}>
        <Button component={Link} to="/">
          Go to Home Page
        </Button>
      </Box>
    </Paper>
  );
}

Great, but let us put this behind a feature flag now. Unlike our development flags, these flags would need a configurable API to control them.

  • We might need to enable the flag based on an ID.
  • We might need to enable the flag based on a search param.
  • If we enable the flag with a search param, we might also want to persist it.

But let us get started based on an ID, we'll quickly build out an API endpoint that gives us user details along with an ID.

src/api/profile.ts
export type ProfileType = {
  id: string;
  firstName: string;
  lastName: string;
  age: number;
  companyName: string;
};
 
export async function fetchProfile() {
  const data: ProfileType = await fetch('http://localhost:9000/profile').then(
    (res) => res.json(),
  );
 
  return data;
}

We now use the API call on the home page and use the ID to conditionally render the feature.

src/pages/home.tsx
import { fetchProfile, type ProfileType } from '@/api/profile';
import FeatureOne from '@/components/feature-one';
import FeatureThree from '@/components/feature-three';
import FeatureTwo from '@/components/FeatureTwo';
import { Box, Button, Paper, Typography } from '@mui/material';
import React from 'react';
import { Link } from 'react-router';
 
export default function Home() {
  const [profile, setProfile] = React.useState<ProfileType | null>(null);
 
  React.useEffect(() => {
    fetchProfile().then((data) => {
      setProfile(data);
    });
  }, []);
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      <Typography variant="h4" gutterBottom>
        Welcome to the Home Page
      </Typography>
      <Typography variant="body1" paragraph>
        This is a simple home page using Material UI v5 components.
      </Typography>
      <Box mt={2}>
        <FeatureOne />
      </Box>
      <Box mt={2}>
        <FeatureTwo />
      </Box>
      {profile?.id === '12345' && (
        <Box mt={2}>
          <FeatureThree />
        </Box>
      )}
      <Box mt={2}>
        <Button component={Link} to="/about">
          Go to About Page
        </Button>
      </Box>
    </Paper>
  );
}

That is it. You can test this out by playing around return data from our mocked handler at src/mocks/handlers.ts

src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
 
const FAKE_PROFILE = {
  id: '12345', // Play here
  firstName: 'John',
  lastName: 'Doe',
  age: 30,
  companyName: 'OpenAI',
};
 
export const handlers = [
  http.get('http://localhost:9000/profile', () => {
    return HttpResponse.json(FAKE_PROFILE, { status: 200 });
  }),
];

Let us now clean up and build out a hook and a component just like our development flags.

export default function useFeatureFlag({
  enableCondition,
}: {
  enableCondition?: boolean;
}) {
  if (enableCondition) {
    return true;
  }
 
  return false;
}
import useFeatureFlag from './use-feature-flag';
 
export default function FeatureFlag({
  enableCondition,
  children,
}: {
  enableCondition?: boolean;
  children?: React.ReactNode;
}) {
  const isEnabled = useFeatureFlag({ enableCondition });
 
  if (isEnabled) {
    return <>{children || null}</>;
  }
 
  return null;
}
src/pages/home.tsx
import { fetchProfile, type ProfileType } from '@/api/profile';
import FeatureFlag from '@/components/feature-flag';
import FeatureOne from '@/components/feature-one';
import FeatureThree from '@/components/feature-three';
import FeatureTwo from '@/components/FeatureTwo';
import { Box, Button, Paper, Typography } from '@mui/material';
import React from 'react';
import { Link } from 'react-router';
export default function Home() {
  const [profile, setProfile] = React.useState<ProfileType | null>(null);
 
  React.useEffect(() => {
    fetchProfile().then((data) => {
      setProfile(data);
    });
  }, []);
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      <Typography variant="h4" gutterBottom>
        Welcome to the Home Page
      </Typography>
      <Typography variant="body1" paragraph>
        This is a simple home page using Material UI v5 components.
      </Typography>
      <Box mt={2}>
        <FeatureOne />
      </Box>
      <Box mt={2}>
        <FeatureTwo />
      </Box>
      <FeatureFlag enableCondition={profile?.id === '12345'}>
        <Box mt={2}>
          <FeatureThree />
        </Box>
      </FeatureFlag>
      <Box mt={2}>
        <Button component={Link} to="/about">
          Go to About Page
        </Button>
      </Box>
    </Paper>
  );
}

That is it! Let us also use this flag on the about page.

src/pages/about.tsx
import { fetchProfile, type ProfileType } from '@/api/profile';
import FeatureFlag from '@/components/feature-flag';
import FeatureOne from '@/components/feature-one';
import FeatureThree from '@/components/feature-three';
import FeatureTwo from '@/components/FeatureTwo';
import { Box, Button, Paper, Typography } from '@mui/material';
import React from 'react';
import { Link } from 'react-router';
 
export default function About() {
  const [profile, setProfile] = React.useState<ProfileType | null>(null);
 
  React.useEffect(() => {
    fetchProfile().then((data) => {
      setProfile(data);
    });
  }, []);
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      <Typography variant="h4" gutterBottom>
        Welcome to the About Page
      </Typography>
      <Typography variant="body1" paragraph>
        This is a simple about page using Material UI v5 components.
      </Typography>
      <Box mt={2}>
        <FeatureOne />
      </Box>
      <Box mt={2}>
        <FeatureTwo />
      </Box>
      <FeatureFlag enableCondition={profile?.id === '12345'}>
        <Box mt={2}>
          <FeatureThree />
        </Box>
      </FeatureFlag>
      <Box mt={2}>
        <Button component={Link} to="/">
          Go to Home Page
        </Button>
      </Box>
    </Paper>
  );
}

But the team still can't use this feature internally

We pushed this feature to a specific user. But our entire team wants to use it now. We can obviously add all their ids but instead putting it behind a search param would be a lot cleaner and simpler. Let us update our feature flag now.

Do note that the way you access search params would depend on the routing library or logic you have.

src/components/use-feature-flag.ts
import { useSearchParams } from 'react-router';
 
export default function useFeatureFlag({
  enableCondition,
  featureName,
}: {
  enableCondition?: boolean;
  featureName?: string;
}) {
  const [searchParams] = useSearchParams();
 
  const isFeatureParamEnabled = featureName
    ? searchParams.get(featureName) === 'true'
    : undefined;
 
  if (enableCondition || isFeatureParamEnabled) {
    return true;
  }
 
  return false;
}
src/components/feature-flag.tsx
import useFeatureFlag from './use-feature-flag';
 
export default function FeatureFlag({
  enableCondition,
  featureName,
  children,
}: {
  enableCondition?: boolean;
  featureName?: string;
  children?: React.ReactNode;
}) {
  const isEnabled = useFeatureFlag({ enableCondition, featureName });
 
  if (isEnabled) {
    return <>{children || null}</>;
  }
 
  return null;
}

We simply design our feature flag API to take an optional featureName, and check if this is set to true in the searchParams we enable the feature.

src/pages/home.tsx
// Code omitted for brevity
 
export default function Home() {
  // Code omitted for brevity
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      {/* Code omitted for brevity */}
 
      <FeatureFlag
        enableCondition={profile?.id === '12345'}
        featureName="feature-three"
      >
        <Box mt={2}>
          <FeatureThree />
        </Box>
      </FeatureFlag>
 
      {/* Code omitted for brevity */}
    </Paper>
  );
}
src/pages/about.tsx
// Code omitted for brevity
 
export default function About() {
  // Code omitted for brevity
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      {/* Code omitted for brevity */}
 
      <FeatureFlag
        enableCondition={profile?.id === '12345'}
        featureName="feature-three"
      >
        <Box mt={2}>
          <FeatureThree />
        </Box>
      </FeatureFlag>
 
      {/* Code omitted for brevity */}
    </Paper>
  );
}

That is it now anyone can set the searchParam for this feature ?feature-three=true and they'll see the feature. You can test it by updating the profile id so it is not enabled for the current user through enableCondition.

src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
 
const FAKE_PROFILE = {
  id: '1234', 
  firstName: 'John',
  lastName: 'Doe',
  age: 30,
  companyName: 'OpenAI',
};
 
export const handlers = [
  http.get('http://localhost:9000/profile', () => {
    return HttpResponse.json(FAKE_PROFILE, { status: 200 });
  }),
];

And then navigate to http://localhost:5173/?feature-three=true

But further navigation loses the flag

Now if you try to go to other pages then the searchParam is lost so is the flag for the feature. Let us add an option to persist the flag.

import React from 'react';
import { useSearchParams } from 'react-router';
 
type StorageType = 'localStorage' | 'sessionStorage' | undefined;
 
export default function useFeatureFlag({
  enableCondition,
  featureName,
  persistTo,
}: {
  enableCondition?: boolean;
  featureName?: string;
  persistTo?: StorageType;
}) {
  const [searchParams] = useSearchParams();
 
  const storedValue =
    persistTo && featureName
      ? window[persistTo].getItem(`featureFlag:${featureName}`)
      : null;
  const isStoredEnabled = storedValue === 'true';
 
  const rawParam = featureName ? searchParams.get(featureName) : null;
  const isFeatureParamEnabled =
    rawParam !== null ? rawParam === 'true' : undefined;
 
  React.useEffect(() => {
    if (persistTo && featureName && isFeatureParamEnabled !== undefined) {
      window[persistTo].setItem(
        `featureFlag:${featureName}`,
        String(isFeatureParamEnabled),
      );
    }
  }, [persistTo, featureName, isFeatureParamEnabled]);
 
  if (enableCondition || isFeatureParamEnabled || isStoredEnabled) {
    return true;
  }
 
  return false;
}
src/components/feature-flag.tsx
import useFeatureFlag from './use-feature-flag';
 
type StorageType = 'localStorage' | 'sessionStorage' | undefined;
 
export default function FeatureFlag({
  enableCondition,
  featureName,
  children,
  persistTo,
}: {
  enableCondition?: boolean;
  featureName?: string;
  children?: React.ReactNode;
  persistTo?: StorageType;
}) {
  const isEnabled = useFeatureFlag({ enableCondition, featureName, persistTo });
 
  if (isEnabled) {
    return <>{children || null}</>;
  }
 
  return null;
}
src/pages/home.tsx
// Code omitted for brevity
 
export default function Home() {
  // Code omitted for brevity
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      {/* Code omitted for brevity */}
 
      <FeatureFlag
        enableCondition={profile?.id === '12345'}
        featureName="feature-three"
        persistTo="sessionStorage"
      >
        <Box mt={2}>
          <FeatureThree />
        </Box>
      </FeatureFlag>
 
      {/* Code omitted for brevity */}
    </Paper>
  );
}
src/pages/about.tsx
// Code omitted for brevity
 
export default function About() {
  // Code omitted for brevity
 
  return (
    <Paper elevation={3} sx={{ p: 4 }}>
      {/* Code omitted for brevity */}
 
      <FeatureFlag
        enableCondition={profile?.id === '12345'}
        featureName="feature-three"
        persistTo="sessionStorage"
      >
        <Box mt={2}>
          <FeatureThree />
        </Box>
      </FeatureFlag>
 
      {/* Code omitted for brevity */}
    </Paper>
  );
}

Previously, if a feature flag was enabled using a search parameter like ?feature-three=true, it would disappear as soon as the user navigated to another page without that query parameter in the URL.

That meant the flag would reset to false unless the enableCondition matched again which is not great for features you want to keep on during a session or across browser restarts.

With the new persistTo option, we can:

  • Store the flag state in localStorage (survives browser restarts) or sessionStorage (resets on tab close).
  • Keep the flag enabled even when navigating to pages that don't have the search param.
  • Only overwrite the stored value when the URL explicitly sets the param.

Basically, the search params now act as a trigger, and storage acts as the memory for your feature flags. This ensures smoother internal testing and avoids having to repeatedly enable a feature while moving around your app.

Great, this is now a very good feature flaggin system we have and should suffice for most usecases on the client side.

But if you notice having the hook and the component in different files is adding a lot of friction while working. Maybe having them in a single file would be a lot cleaner. Let us do that refactoring now.

Collocate Feature Flag Logic

Let us bring those files together into a single file and also move this file to a utils folder.

src/utils/feature-flag.tsx
import React from 'react';
import { useSearchParams } from 'react-router';
 
type StorageType = 'localStorage' | 'sessionStorage' | undefined;
 
type FeatureFlag = {
  enableCondition?: boolean;
  featureName?: string;
  persistTo?: StorageType;
};
 
// Check this issue: https://github.com/ArnaudBarre/eslint-plugin-react-refresh/issues/84
// eslint-disable-next-line react-refresh/only-export-components
export function useFeatureFlag({
  enableCondition,
  featureName,
  persistTo,
}: FeatureFlag) {
  const [searchParams] = useSearchParams();
 
  const storedValue =
    persistTo && featureName
      ? window[persistTo].getItem(`featureFlag:${featureName}`)
      : null;
  const isStoredEnabled = storedValue === 'true';
 
  const rawParam = featureName ? searchParams.get(featureName) : null;
  const isFeatureParamEnabled =
    rawParam !== null ? rawParam === 'true' : undefined;
 
  React.useEffect(() => {
    if (persistTo && featureName && isFeatureParamEnabled !== undefined) {
      window[persistTo].setItem(
        `featureFlag:${featureName}`,
        String(isFeatureParamEnabled),
      );
    }
  }, [persistTo, featureName, isFeatureParamEnabled]);
 
  if (enableCondition || isFeatureParamEnabled || isStoredEnabled) {
    return true;
  }
 
  return false;
}
 
export default function FeatureFlag({
  enableCondition,
  featureName,
  children,
  persistTo,
}: FeatureFlag & { children?: React.ReactNode }) {
  const isEnabled = useFeatureFlag({ enableCondition, featureName, persistTo });
 
  if (isEnabled) {
    return <>{children || null}</>;
  }
 
  return null;
}

Do note that you will see an eslint warning which we disabled. In fact, the HMR here is a bit broken because of our collacation and for this file which would infrequently change it is completely fine! Let us update our imports and clean up things.

src/pages/home.tsx
// Code ommited for brevity
 
import FeatureFlag from '@/utils/feature-flag';
 
// Code ommited for brevity
src/pages/about.tsx
// Code ommited for brevity
 
import FeatureFlag from '@/utils/feature-flag';
 
// Code ommited for brevity

Let us also collocate our development flags:

src/utils/development-flag.tsx
// Check this issue: https://github.com/ArnaudBarre/eslint-plugin-react-refresh/issues/84
// eslint-disable-next-line react-refresh/only-export-components
export function useDevelopmentFlag() {
  return process.env.NODE_ENV === 'development';
}
 
export default function DevelopmentFlag({
  children,
}: {
  children: React.ReactNode;
}) {
  const isDev = useDevelopmentFlag();
 
  return isDev ? <>{children}</> : null;
}

And fix our imports in feature-one.tsx

src/components/feature-one.tsx
// Code ommited for brevity
 
import DevelopmentFlag from '@/utils/development-flag';
 
// Code ommited for brevity

In the next section, we will add one last improvement and talk about feature flags from server-side perspective.

At this point, your code should be a good match to the branch of the repository: 2-feature-flags