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>;| Prop | Meaning |
|---|---|
features | the store, built by createFeatures |
context | the evaluation context |
decisions | decisions 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 />;
}| Hook | Returns |
|---|---|
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.