Skip to content
quonfigPublic

About

Quonfig SDK for React — feature flags, live config, and dynamic log levels

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

@quonfig/react

A React provider and hook for Quonfig

Installation

npm install @quonfig/react @quonfig/javascript
# or
yarn add @quonfig/react @quonfig/javascript

@quonfig/javascript is a peer dependency. npm 7+ installs peers for you, but Yarn does not, so list it explicitly. TypeScript types are included with the package.

Usage in your app

Configure the Provider

Wrap your component tree in the QuonfigProvider, e.g.

import { QuonfigProvider } from "@quonfig/react";

const WrappedApp = () => {
  const context = {
    user: { email: "jeffrey@example.com" },
    subscription: { plan: "advanced" },
  };

  const onError = (error) => {
    console.error(error);
  };

  return (
    <QuonfigProvider sdkKey={"YOUR_SDK_KEY"} contextAttributes={context} onError={onError}>
      <App />
    </QuonfigProvider>
  );
};

Here's an explanation of each provider prop:

property required type purpose
sdkKey yes string your Quonfig SDK key
onError no (error) => void callback invoked if quonfig fails to initialize
contextAttributes no Contexts this is the context attributes object you passed when setting up the provider
timeout no number per-request fetch timeout in ms (defaults to ~3s). Keep it above hedgeDelay — see note below.
hedgeDelay no number ms the hedged loader waits for the primary before also firing the secondary in parallel (defaults to ~2s).
pollInterval no number configures quonfig to poll for updates every pollInterval ms.
initialFlags no Record<string, unknown> seed flag values evaluated on the server — see Next.js / RSC integration below.
onInitFailure no "fail" | "wait" | "continue" what happens when no config is available by initTimeout — see Startup failure.
initTimeout no number overall ms init() waits for config before onInitFailure applies (client default 5000).
logger no Logger receives the SDK's log lines instead of console — see Routing SDK logs.

timeout must stay above hedgeDelay. The loader fires the primary URL first and, if it is slow (no answer within hedgeDelay), also fires the secondary in parallel. If you set timeout at or below hedgeDelay, the primary is aborted before the hedge can fire, degrading the parallel hedge to error-only failover (the secondary is only tried after the primary fully times out). The SDK logs a warning when this happens. Raise hedgeDelay toward your primary's measured p99 to contact the secondary less often.

Routing SDK logs (logger)

By default the provider and the client write their log lines to console. Pass a logger to send them to your own logger instead:

<QuonfigProvider sdkKey={"YOUR_SDK_KEY"} contextAttributes={context} logger={myLogger}>
  <App />
</QuonfigProvider>

Logger is the same shape as the logger option of @quonfig/javascript and @quonfig/node: warn and error are required, debug and info are optional (lines at a missing level are dropped). console, Pino, Winston and Bunyan loggers fit it directly.

The provider uses the logger for its own lines (missing contextAttributes, a failed first poll, the default onError fallback) and forwards it to the client's init(). The client's own lines, such as [quonfig] init FAILED and init recovered, follow it from the @quonfig/javascript release after 2.0.0. With @quonfig/javascript 2.0.0 and earlier the client ignores it, and those lines still go to console.

Usage in Your Components

Use the useQuonfig hook to fetch flags and config values:

const Logo = () => {
  const { isEnabled } = useQuonfig();

  if (isEnabled("new-logo")) {
    return <img src={newLogo} className="App-logo" alt="logo" />;
  }

  return <img src={logo} className="App-logo" alt="logo" />;
};

useQuonfig exposes the following:

const { isEnabled, get, loading, isReady, error, source, contextAttributes } = useQuonfig();

Here's an explanation of each property:

property example purpose
isEnabled isEnabled("new-logo") true only for a boolean flag whose value is true; false otherwise (never throws)
get get('retry-count') returns the value of a flag or config
getDuration getDuration('t', d) a { ms, seconds } duration, or the default d (undefined when omitted); never throws
loading if (loading) { ... } a boolean indicating whether quonfig content is being loaded
isReady if (!isReady) { ... } true while serving live config (or SSR initialFlags); false on a cached copy or code defaults
error error?.message the error the latest init rejected with; undefined once ready
source source === "defaults" "network", "cache" (last-known-good copy from an earlier visit) or "defaults"
contextAttributes N/A this is the context attributes object you passed when setting up the provider
quonfig N/A the underlying JavaScript quonfig instance
keys N/A an array of all the flag and config names in the current configuration

isEnabled and getDuration follow the typed-getter contract every Quonfig SDK shares (see the @quonfig/javascript README): no string coercion (a stored "true" is not enabled, a stored string "PT1S" is not a duration), and a value of the wrong type returns your default with one console.warn per key.

Startup failure

If every delivery leg is down at page load, the client retries until initTimeout (5s by default) and then applies onInitFailure:

  • "fail" (default): onError fires and error is set. A cached last-known-good copy from an earlier visit counts as config, so a returning visitor starts on it (source: "cache") and no error is raised. Only a first visit with no cache lands on code defaults (source: "defaults").
  • "wait": loading stays true until config arrives.
  • "continue": start on the cache or code defaults without an error.

In every mode the client keeps retrying in the background and heals: once delivery answers, isReady turns true, error clears and the hooks re-render with live values.

const { isReady, source } = useQuonfig();
if (!isReady && source === "defaults") console.warn("Quonfig: serving code defaults");

onInitFailure, initTimeout and the background retry without pollInterval come from @quonfig/javascript 1.6.0; with 1.5.x the props are ignored, a failed init heals through pollInterval only, and isReady/error/source still work.

useFlag — per-key selector hook

useQuonfig() re-renders every consumer when any flag value changes. For components that only care about one flag, use useFlag(key) — it subscribes to that single key and skips re-renders when unrelated flags change.

import { useFlag } from "@quonfig/react";

const Logo = () => {
  const showNewLogo = useFlag("new-logo");
  return <img src={showNewLogo ? newLogo : logo} alt="logo" />;
};

If you have a typed config (via @quonfig/cli generate), useFlag returns the type declared for the key.

Next.js / RSC integration

The provider runs on the client (it uses fetch and useEffect), but you often want to seed flag values on the server so the first paint reflects real data instead of defaults. Pass evaluated flags through the initialFlags prop on QuonfigProvider.

App Router (RSC)

Fetch flags in a Server Component, then pass the evaluated map to a Client Component that owns the provider. QuonfigProvider itself must be inside a Client Component because of its hooks.

// app/quonfig-wrapper.tsx — Client Component
"use client";

import { QuonfigProvider } from "@quonfig/react";

export function QuonfigWrapper({ children, initialFlags, contextAttributes }) {
  return (
    <QuonfigProvider
      sdkKey={process.env.NEXT_PUBLIC_QUONFIG_API_KEY!}
      contextAttributes={contextAttributes}
      initialFlags={initialFlags}
    >
      {children}
    </QuonfigProvider>
  );
}
// app/layout.tsx — Server Component
import { QuonfigWrapper } from "./quonfig-wrapper";

export default async function RootLayout({ children }) {
  // Evaluate flags on the server with @quonfig/node (or similar) and
  // hand the flat key/value map to the client provider.
  const initialFlags = await evaluateFlagsOnServer({
    user: { id: "1" },
  });

  return (
    <html>
      <body>
        <QuonfigWrapper initialFlags={initialFlags} contextAttributes={{ user: { id: "1" } }}>
          {children}
        </QuonfigWrapper>
      </body>
    </html>
  );
}

initialFlags is the flat-map shape { flagKey: value }, e.g. { "new-logo": true, "retry-count": 3 }. When initialFlags is set, the provider hydrates synchronously on first render — no loading flicker, loading is false and isReady is true. In the browser it then connects in the background: one fetch for the same context (the seed keeps serving until it answers), evaluation telemetry, and polling if you set pollInterval. Subsequent context changes trigger fetches as usual.

Pages Router (getServerSideProps)

export async function getServerSideProps({ req }) {
  const initialFlags = await evaluateFlagsOnServer(extractContext(req));
  return { props: { initialFlags } };
}

export default function Page({ initialFlags }) {
  return (
    <QuonfigProvider sdkKey={process.env.NEXT_PUBLIC_QUONFIG_API_KEY} initialFlags={initialFlags}>
      <App />
    </QuonfigProvider>
  );
}

Hydration mismatch caveats

  • Pass the same contextAttributes on the server and the client. If the client has different inputs (e.g. it adds attributes from document or window), the first client render will fetch a different evaluation and flag values can flicker.
  • initialFlags only applies on the first render. Don't expect changing it later to swap flags out — use setConfig/poll for that.
  • initialFlags and pollInterval combine: the seed paints first, then the provider polls every pollInterval ms.

SSR / multi-tenant rendering

QuonfigProvider does render on the server: a "use client" component is still server-rendered for the first HTML. On the server (typeof window === "undefined"), every provider gets its own Quonfig() client, seeded only from that render's initialFlags. It never reads or writes the module singleton there, so one request's flags cannot appear in another request's HTML. The server render runs no effects, so it does no fetching and no polling.

In the browser, the provider's client identity is keyed by React tree position via QuonfigClientContext: a top-level provider claims the module singleton (so import { quonfig } consumers see the same instance), and any nested QuonfigProvider mints a fresh Quonfig() so its config can't leak into the parent tree. Mount one top-level provider per page: sibling top-level providers (including ones in separate React roots) share that one singleton, so the last to load sets the context for all of them and unmounting any of them stops polling for the rest; nest a provider when you need an independent client.

To build initialFlags on the server, use a new client per request rather than the shared quonfig singleton. Concurrent requests that init() the singleton can overwrite each other's flags:

import { Quonfig } from "@quonfig/javascript";

const client = new Quonfig();
await client.init({ sdkKey, context, collectEvaluationSummaries: false });
const initialFlags = client.extract();

Usage in your test suite

Wrap the component under test in a QuonfigTestProvider and provide a config object to set up your test state. It is imported from @quonfig/react/testing (since 2.0.0), so it stays out of your production bundle; the hooks still come from @quonfig/react.

e.g. if you wanted to test the following trivial component

function MyComponent() {
  const { get, isEnabled, loading } = useQuonfig();
  const greeting = get("greeting") || "Greetings";

  if (loading) {
    return <div>Loading...</div>;
  }

  return (
    <div>
      <h1 role="alert">{greeting}</h1>
      {isEnabled("secretFeature") && (
        <button type="submit" title="secret-feature">
          Secret feature
        </button>
      )}
    </div>
  );
}

You could do the following in jest/rtl

import { QuonfigTestProvider } from '@quonfig/react/testing';

const renderInTestProvider = (config: {[key: string]: any}) => {
  render(
    <QuonfigTestProvider config={config}>
      <MyComponent />
    </QuonfigTestProvider>,
  );
};

it('shows a custom greeting', async () => {
  renderInTestProvider({ greeting: 'Hello' });

  const alert = screen.queryByRole('alert');
  expect(alert).toHaveTextContent('Hello');
});

it('shows the secret feature when it is enabled', async () => {
  renderInTestProvider({ secretFeature: true });

  const secretFeature = screen.queryByTitle('secret-feature');
  expect(secretFeature).toBeInTheDocument();
});

QuonfigTestProvider answers isEnabled and getDuration the way the real client does: isEnabled is true only for true, and getDuration takes an ISO 8601 string ("PT30S") or a parsed { ms, seconds } and returns the default for anything else. Neither throws.

Contributing

Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. For detailed contributing guidelines, please see CONTRIBUTING.md

About

Quonfig SDK for React — feature flags, live config, and dynamic log levels

Topics

Resources

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages