@arnosaine/is v0.2.9
@arnosaine/is
Feature Flags, Roles and Permissions-based rendering, A/B Testing, Experimental Features, and more in React.
Key Features
- Declarative syntax for conditionally rendering components
- Support for various data sources, including context, hooks, and API responses
- Customizable with default conditions and dynamic values
Create a custom <Is> component and useIs hook for any conditional rendering use cases.
Or create shortcut components like <IsAuthenticated>, <HasRole> / <Role> and <HasPermission> / <Can>, and hooks like useIsAuthenticated, useHasRole / useRole and useHasPermission / useCan, for the most common use cases.
If you are using React Router or Remix, use createFromLoader to also create loadIs loader and utility functions like authenticated.
Contents
Demos
Getting Started
Here, we create a component and a hook to check if the user is authenticated or if experimental features are enabled. We get the user from UserContext. Experimental features are enabled on preview.* domains, for example, at http://preview.localhost:5173.
Create <Is> & useIs
./is.ts:
import { create } from "@arnosaine/is";
import { use } from "react";
import UserContext from "./UserContext";
const [Is, useIs] = create(function useValues() {
const user = use(UserContext);
const isExperimental = location.hostname.startsWith("preview.");
// Or, get the value from the user context, a hook call, or another
// source.
// const isExperimental = user?.roles?.includes("developer") ?? false;
return {
// The property names become the prop and hook param names.
// Allowed types: boolean | number | string | boolean[] | number[] |
// string[].
authenticated: Boolean(user),
experimental: isExperimental,
// ...
};
});
export { Is, useIs };Use <Is> & useIs
import { Is, useIs } from "./is";
// Component
<Is authenticated fallback="Please log in">
Welcome back!
</Is>;
<Is experimental>
<SomeExperimentalFeature />
</Is>;
// Hook
const isAuthenticated = useIs({ authenticated: true }); // boolean
const isExperimental = useIs({ experimental: true }); // booleanℹ️ Consider lazy loading if the conditional code becomes large. Otherwise, the conditional code is included in the bundle, even if it's not rendered. Additionally, do not use this method if the non-rendered code should remain secret.
Ideas
Feature Flags
Hardcoded Features
A list of hardcoded features is perhaps the simplest method and can still improve the project workflow. For example, some features can be enabled in the release branch, while different features can be enabled in the main or feature branches.
./is.ts:
import { create } from "@arnosaine/is";
const [Is, useIs] = create(function useValues() {
return {
// Hardcoded features
feature: ["feature-abc", "feature-xyz"] as const,
// ...
};
});
export { Is, useIs };Build Time Features
Read the enabled features from an environment variable at build time:
.env:
FEATURES=["feature-abc","feature-xyz"]./is.ts:
import { create } from "@arnosaine/is";
const [Is, useIs] = create(function useValues() {
return {
// Read the enabled features from an environment variable at build
// time
feature: JSON.parse(import.meta.env.FEATURES ?? "[]"),
// ...
};
});
export { Is, useIs };Runtime Features
Read the enabled features from a config file or an API at runtime:
public/config.json:
{
"features": ["feature-abc", "feature-xyz"]
}./is.ts:
import { create } from "@arnosaine/is";
import { use } from "react"; // React v19
async function getConfig() {
const response = await fetch(import.meta.env.BASE_URL + "config.json");
return response.json();
}
const configPromise = getConfig();
const [Is, useIs] = create(function useValues() {
const config = use(configPromise);
return {
feature: config.features,
// ...
};
});
export { Is, useIs };A/B Testing, Experimental Features
Enable some features based on other values:
./is.ts:
import { create } from "@arnosaine/is";
const [Is, useIs] = create(function useValues() {
const features = [
/*...*/
];
// Enable some features only in development mode:
if (import.meta.env.MODE === "development") {
features.push("new-login-form");
}
// Or, enable some features only on `dev.*` domains, for example, at
// http://dev.localhost:5173:
if (location.hostname.startsWith("dev.")) {
features.push("new-landing-page");
}
return {
feature: features,
// ...
};
});
export { Is, useIs };Enable All Features in Preview Mode
./is.ts:
import { create } from "@arnosaine/is";
const [Is, useIs] = create(function useValues() {
const features = [
/*...*/
];
const isPreview = location.hostname.startsWith("preview.");
return {
feature: isPreview
? // In preview mode, all features are enabled.
// Typed as string to accept any string as a feature name.
(true as unknown as string)
: features,
// ...
};
});
export { Is, useIs };Usage
It does not matter how the features are defined; using the <Is> and useIs is the same:
import { Is, useIs } from "./is";
// Component
<Is feature="new-login-form" fallback={<OldLoginForm />}>
<NewLoginForm />
</Is>;
// Hook
const showNewLoginForm = useIs({ feature: "new-login-form" });Application Variants by the Domain
ℹ️ In the browser,
location.hostnameis a constant, andlocation.hostname === "example.com" && <p>This appears only on example.com</p>could be all you need. You might still choose to use the Is pattern for consistency and for server-side actions and loaders.
./is.ts:
import { create } from "@arnosaine/is";
const [Is, useIs] = create(function useValues() {
const domain = location.hostname.endsWith(".localhost")
? // On <domain>.localhost, get subdomain.
location.hostname.slice(0, -".localhost".length)
: location.hostname;
return {
variant: domain,
// ...
};
});
export { Is, useIs };Usage
import { Is, useIs } from "./is";
// Component
<Is variant="example.com">
<p>This appears only on example.com</p>
</Is>;
// Hook
const isExampleDotCom = useIs({ variant: "example.com" });User Roles and Permissions
./is.ts:
import { create } from "@arnosaine/is";
import { use } from "react";
import UserContext from "./UserContext";
const [Is, useIs] = create(function useValues() {
const user = use(UserContext);
return {
authenticated: Boolean(user),
role: user?.roles, // ["admin", ...]
permission: user?.permissions, // ["create-articles", ...]
// ...
};
});
export { Is, useIs };Usage
import { Is, useIs } from "./is";
// Component
<Is authenticated fallback="Please log in">
Welcome back!
</Is>;
<Is role="admin">
<AdminPanel />
</Is>;
<Is permission="update-articles">
<button>Edit</button>
</Is>;
// Hook
const isAuthenticated = useIs({ authenticated: true });
const isAdmin = useIs({ role: "admin" });
const canUpdateArticles = useIs({ permission: "update-articles" });Is a Specific Day
./is.ts:
import { create } from "@arnosaine/is";
import { easter } from "date-easter";
import { isSameDay } from "date-fns";
const [Is, useIs] = create(function useValues() {
return {
easter: isSameDay(new Date(easter()), new Date()),
// ...
};
});
export { Is, useIs };Usage
import { Is, useIs } from "./is";
// Component
<Is easter>🐣🐣🐣</Is>;
// Hook
const isEaster = useIs({ easter: true });Shortcut Components and Hooks
import { create } from "@arnosaine/is";
import { use } from "react";
import UserContext from "./UserContext";
const [IsAuthenticated, useIsAuthenticated] = create(
function useValues() {
const user = use(UserContext);
return { authenticated: Boolean(user) };
},
{ authenticated: true } // Default props / hook params
);
<IsAuthenticated fallback="Please log in">Welcome back!</IsAuthenticated>;
const isAuthenticated = useIsAuthenticated();import { create, toBooleanValues } from "@arnosaine/is";
import { use } from "react";
import UserContext from "./UserContext";
const [HasRole, useHasRole] = create(function useValues() {
const user = use(UserContext);
// Create object { [role: string]: true }
return Object.fromEntries((user?.roles ?? []).map((role) => [role, true]));
});
<HasRole admin>
<AdminPanel />
</HasRole>;
const isAdmin = useHasRole({ admin: true });
// Same with toBooleanValues utility
const [Role, useRole] = create(() => toBooleanValues(use(UserContext)?.roles));
<Role admin>
<AdminPanel />
</Role>;
const isAdmin = useRole({ admin: true });import { create, toBooleanValues } from "@arnosaine/is";
import { use } from "react";
import UserContext from "./UserContext";
const [HasPermission, useHasPermission] = create(function useValues() {
const user = use(UserContext);
// Create object { [permission: string]: true }
return Object.fromEntries(
(user?.permissions ?? []).map((permission) => [permission, true])
);
});
<HasPermission update-articles>
<button>Edit</button>
</HasPermission>;
const canUpdateArticles = useHasPermission({ "update-articles": true });
// Same with toBooleanValues utility
const [Can, useCan] = create(() =>
toBooleanValues(use(UserContext)?.permissions)
);
<Can update-articles>
<button>Edit</button>
</Can>;
const canUpdateArticles = useCan({ "update-articles": true });For a Very Specific Use Case
import { create } from "@arnosaine/is";
import { use } from "react";
import UserContext from "./UserContext";
const [CanUpdateArticles, useCanUpdateArticles] = create(
function useValues() {
const user = use(UserContext);
return {
updateArticles: user?.permissions?.includes("update-articles") ?? false,
};
},
{ updateArticles: true } // Default props / hook params
);
<CanUpdateArticles>
<button>Edit</button>
</CanUpdateArticles>;
const canUpdateArticles = useCanUpdateArticles();Loader (React Router / Remix)
Setup
Create
<Is>,useIs&loadIsusingcreateFromLoader../app/is.ts:import { createFromLoader } from "@arnosaine/is"; import { loadConfig, loadUser } from "./loaders"; const [Is, useIs, loadIs] = createFromLoader(async (args) => { const { hostname } = new URL(args.request.url); const isPreview = hostname.startsWith("preview."); const user = await loadUser(args); const config = await loadConfig(args); return { authenticated: Boolean(user), feature: config?.features, preview: isPreview, role: user?.roles, // ... }; }); export { Is, useIs, loadIs };./app/root.tsx:Return
...isfrom the rootloader/clientLoader. See options to use other route.import { loadIs } from "./is"; export const loader = async (args: LoaderFunctionArgs) => { const is = await loadIs(args); return { ...is, // ...other loader data... }; };
ℹ️ The root
ErrorBoundarydoes not have access to the rootloaderdata. Since the rootLayoutexport is shared with the rootErrorBoundary, if you use<Is>oruseIsin theLayoutexport, consider prefixing all routes with_.(pathless route) and usingErrorBoundaryinroutes/_.tsxto catch errors before they reach the rootErrorBoundary.
Using loadIs
import { loadIs } from "./is";
// Or clientLoader
export const loader = async (args: LoaderFunctionArgs) => {
const is = await loadIs(args);
const isAuthenticated = is({ authenticated: true });
const hasFeatureABC = is({ feature: "feature-abc" });
const isPreview = is({ preview: true });
const isAdmin = is({ role: "admin" });
// ...
};Utilities
ℹ️ See Remix example utils/auth.ts and utils/response.ts for more examples.
./app/utils/auth.tsx:
import { loaderFunctionArgs } from "@remix-run/node";
import { loadIs } from "./is";
export const authenticated = async (
args: LoaderFunctionArgs,
role?: string | string[]
) => {
const is = await loadIs(args);
// Ensure user is authenticated
if (!is({ authenticated: true })) {
throw new Response("Unauthorized", {
status: 401,
});
}
// If the optional role parameter is available, ensure the user has
// the required roles
if (!is({ role })) {
throw new Response("Forbidden", {
status: 403,
});
}
};import { authenticated } from "./utils/auth";
export const loader = async (args: LoaderFunctionArgs) => {
await authenticated(args, "admin");
// User is authenticated and has the role "admin".
// ...
};API
create
Call create to declare the Is component and the useIs hook.
const [Is, useIs] = create(useValues, defaultConditions?);The names Is and useIs are recommended for a multi-purpose component and hook. For single-purpose use, you can name them accordingly. The optional defaultConditions parameter is also often useful for single-purpose implementations.
const [IsAuthenticated, useIsAuthenticated] = create(
() => {
// Retrieve the user. Since this is a hook, using other hooks and
// context is allowed.
const user = { name: "Example" }; // Example: use(UserContext)
return { authenticated: Boolean(user) };
},
{ authenticated: true }
);Parameters
useValues: A React hook that acquires and computes the currentvaluesfor the comparison logic.- optional
defaultConditions: The default props/params forIsanduseIs. - optional
options: An options object for configuring the behavior.- optional
method("every" | "some"): Default:"some". Specifies how to match array type values and conditions. Use"some"to require only some conditions to match the values, or"every"to require all conditions to match.
- optional
Returns
create returns an array containing the Is component and the useIs hook.
createFromLoader
Call createFromLoader to declare the Is component the useIs hook and the loadIs loader.
const [Is, useIs, loadIs] = createFromLoader(loadValues, defaultConditions?, options?);The names Is, useIs and loadIs are recommended for a multi-purpose component, hook, and loader. For single-purpose use, you can name them accordingly. The optional defaultConditions parameter is also often useful for single-purpose implementations.
const [IsAuthenticated, useIsAuthenticated, loadIsAuthenticated] =
createFromLoader(
async (args) => {
// Retrieve the user. Since this is a loader, using await and
// other loaders is allowed.
const user = await loadUser(args);
return { authenticated: Boolean(user) };
},
{ authenticated: true }
);Parameters
loadValues: A React Router / Remix loader function that acquires and computes the currentvaluesfor the comparison logic.- optional
defaultConditions: The default props/params forIs,useIsandis. - optional
options: An options object for configuring the behavior.- optional
method("every" | "some"): Default:"some". Specifies how to match array type values and conditions. Use"some"to require only some conditions to match the values, or"every"to require all conditions to match. - optional
prop: Default:"__is_values".isobject (function) property that is expected to be returned in the root loader data. - optional
routeId: Default: The root route ID ("root"or"0"). The route that provides theis.__is_valuesfrom its loader. Example:"routes/admin".
- optional
Returns
createFromLoader returns an array containing the Is component, the useIs hook and the loadIs loader.
<Is>
Props
...conditions: Conditions are merged with thedefaultConditionsand then compared to theuseValues/loadValuesreturn value. If multiple conditions are given, all must match their corresponding values. For any array-type condition:- If the corresponding value is also an array and
options.methodis"some"(default), the value array must include at least one of the condition entries. Ifoptions.methodis"every", the value array must include all condition entries. - If the corresponding value is not an array, the value must be one of the condition entries.
- If the corresponding value is also an array and
- optional
children: The UI you intend to render if all conditions match. - optional
fallback: The UI you intend to render if some condition does not match.
Usage
<Is authenticated fallback="Please log in">
Welcome back!
</Is>
<IsAuthenticated fallback="Please log in">Welcome back!</IsAuthenticated>useIs
Parameters
conditions: Conditions are merged with thedefaultConditionsand then compared to theuseValues/loadValuesreturn value. If multiple conditions are given, all must match their corresponding values. For any array-type condition:- If the corresponding value is also an array and
options.methodis"some"(default), the value array must include at least one of the condition entries. Ifoptions.methodis"every", the value array must include all condition entries. - If the corresponding value is not an array, the value must be one of the condition entries.
- If the corresponding value is also an array and
Returns
useIs returns true if all conditions match, false otherwise.
Usage
const isAuthenticated = useIs({ authenticated: true });
const isAuthenticated = useIsAuthenticated();loadIs
Parameters
args: React Router / RemixLoaderFunctionArgs,ActionFunctionArgs,ClientLoaderFunctionArgs, orClientActionFunctionArgs.
Returns
loadIs returns a Promise that resolves to the is function.
Usage
export const loader = async (args: LoaderFunctionArgs) => {
const is = await loadIs(args);
const authenticated = await loadIsAuthenticated(args);
const isAuthenticated = is({ authenticated: true });
const isAuthenticated = authenticated();
// ...
};is
is function is the awaited return value of calling loadIs.
Parameters
conditions: Conditions are merged with thedefaultConditionsand then compared to theuseValues/loadValuesreturn value. If multiple conditions are given, all must match their corresponding values. For any array-type condition:- If the corresponding value is also an array and
options.methodis"some"(default), the value array must include at least one of the condition entries. Ifoptions.methodis"every", the value array must include all condition entries. - If the corresponding value is not an array, the value must be one of the condition entries.
- If the corresponding value is also an array and
Returns
is returns a true if all conditions match, false otherwise.
Usage
In root.tsx you must also return ...is from the loader / clientLoader. See options to use other route.
export const loader = async (args: LoaderFunctionArgs) => {
const is = await loadIs(args);
return {
...is,
// ...other loader data...
};
};toBooleanValues
Call toBooleanValues to convert an array of strings to an object with true values.
const permissionList = [
"create-articles",
"read-articles",
"update-articles",
"delete-articles",
];
const permissionValues = toBooleanValues(permissions);
// { "create-articles": true, "read-articles": true, ... }Parameters
- optional
strings: An array of strings.
Returns
toBooleanValues returns an object with true values.
Types
Value
- Type
Valueisboolean | number | string. - It may also be more specific, like a union of
stringvalues.
Example
const features = ["feature-abc", "feature-xyz"] as const;
// "feature-abc" | "feature-xyz"
type Feature = (typeof features)[number];Values
- Type
ValuesisRecord<string, Value | Value[]>.
Example
{
"authenticated": true,
"roles": ["admin"],
"permissions": [
"create-articles",
"read-articles",
"update-articles",
"delete-articles"
]
}Conditions
- Type
ConditionsisPartial<Values>.
Example
{
"roles": "admin"
}