Skip to Content
FeatureReact

React

Not on npm yet

npm install @evanion/feature does not resolve. The package is private: true, so a release run versions and tags it without publishing: npm cannot configure a trusted publisher for a package that does not exist on the registry, and the first version has to go up by hand.

'use client'; import { FeatureProvider, useFeature, useFeatureEnabled, useFeatures, } from '@evanion/feature/react';

A provider and three hooks over an already-built store. Nothing here decides anything — evaluation lives in the core, and this layer carries the result down the tree.

The entry is separate from @evanion/feature because 'use client' is a per-module directive and a bundle is one module. Keeping the core entry free of the directive and of any React import is what lets an API, a script or a build step import it.

React is an optional peer dependency, so a consumer that only uses the core does not install it.

FeatureProvider

const features = createFeatures(config); const context = { targetingKey: user.id, now: new Date() }; <FeatureProvider features={features} context={context}> <Checkout /> </FeatureProvider>;
PropMeaning
featuresthe store, built by createFeatures
contextthe evaluation context
decisionsdecisions resolved elsewhere, used as they are

Resolution is memoised on the context object’s identity. An object literal written inline is a fresh identity on every render, so it re-resolves every render:

// Re-resolves the whole store on every render. <FeatureProvider features={features} context={{ targetingKey: user.id }}>

Hold the context in a useMemo, in a module constant, or wherever the data it comes from already lives:

const context = useMemo(() => ({ targetingKey: user.id, now }), [user.id, now]);

Leaving now out means “the clock at the moment this context was first resolved”. A tree that has to agree with a server render should pass now explicitly, with the same instant the server used.

Decisions from elsewhere

<FeatureProvider features={features} decisions={fromServer}>

Supplied decisions are used as they are, and features and context become only a fallback for what the snapshot does not cover. That is the hand-off for a server render, and for the settled half of a plan().

The hooks

function Checkout() { if (!useFeatureEnabled('express-pickup')) return <LegacyCheckout />; return <NewCheckout />; }
HookReturns
useFeatures()every decision
useFeature(key)one decision, explanation included
useFeatureEnabled(key)the boolean

All three throw outside a <FeatureProvider>, naming the provider and the package.

useFeature also throws for a key that is not configured in that provider:

feature "express-delivery" is not configured in this <FeatureProvider>

A silent false would make a typo indistinguishable from a feature that is off. useFeature is the hook that carries the explanation, for showing an operator why a flag is off:

const decision = useFeature('express-pickup'); // { key, enabled, reason, rule?, rules?, blockedBy?, cause? }

The key lookup uses Object.prototype.hasOwnProperty, so a key of constructor or toString does not resolve to a function off Object.prototype and pass as a decision object.

Typing the keys

type Flag = 'new-checkout' | 'express-pickup'; const decisions = useFeatures<Flag>(); const enabled = useFeatureEnabled<Flag>('express-pickup');

The hooks take the key union as a type parameter, defaulting to string. Pass the same union you passed createFeatures and a typo is a compile error rather than a thrown one.

Server components

FeatureProvider uses createContext and useMemo, so it is client code and carries 'use client'. The core is not: resolve on the server, and hand the result down.

// app/layout.tsx — a Server Component import { features } from '../features'; import { Flags } from './flags'; export default async function Layout({ children }) { const decisions = features.resolve({ targetingKey: await currentUserId() }); return <Flags decisions={decisions}>{children}</Flags>; }
// app/flags.tsx 'use client'; import { FeatureProvider } from '@evanion/feature/react'; export function Flags({ decisions, children }) { return ( <FeatureProvider features={features} decisions={decisions}> {children} </FeatureProvider> ); }

Decisions are plain objects, so they cross the boundary as serialised props. Passing the store itself would not — it carries functions.

Last updated on