@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.");
return {
authenticated: Boolean(user),
experimental: isExperimental,
};
});
export { Is, useIs };
Use <Is>
& useIs
import { Is, useIs } from "./is";
<Is authenticated fallback="Please log in">
Welcome back!
</Is>;
<Is experimental>
<SomeExperimentalFeature />
</Is>;
const isAuthenticated = useIs({ authenticated: true });
const isExperimental = useIs({ experimental: true });
ℹ️ 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 {
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 {
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";
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 = [
];
if (import.meta.env.MODE === "development") {
features.push("new-login-form");
}
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
?
(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";
<Is feature="new-login-form" fallback={<OldLoginForm />}>
<NewLoginForm />
</Is>;
const showNewLoginForm = useIs({ feature: "new-login-form" });
Application Variants by the Domain
ℹ️ In the browser, location.hostname
is a constant, and location.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")
?
location.hostname.slice(0, -".localhost".length)
: location.hostname;
return {
variant: domain,
};
});
export { Is, useIs };
Usage
import { Is, useIs } from "./is";
<Is variant="example.com">
<p>This appears only on example.com</p>
</Is>;
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,
permission: user?.permissions,
};
});
export { Is, useIs };
Usage
import { Is, useIs } from "./is";
<Is authenticated fallback="Please log in">
Welcome back!
</Is>;
<Is role="admin">
<AdminPanel />
</Is>;
<Is permission="update-articles">
<button>Edit</button>
</Is>;
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";
<Is easter>🐣🐣🐣</Is>;
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 }
);
<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);
return Object.fromEntries((user?.roles ?? []).map((role) => [role, true]));
});
<HasRole admin>
<AdminPanel />
</HasRole>;
const isAdmin = useHasRole({ admin: true });
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);
return Object.fromEntries(
(user?.permissions ?? []).map((permission) => [permission, true])
);
});
<HasPermission update-articles>
<button>Edit</button>
</HasPermission>;
const canUpdateArticles = useHasPermission({ "update-articles": true });
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 }
);
<CanUpdateArticles>
<button>Edit</button>
</CanUpdateArticles>;
const canUpdateArticles = useCanUpdateArticles();
Loader (React Router / Remix)
Setup
-
Create <Is>
, useIs
& loadIs
using createFromLoader
.
./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 is.__values
as __is
from the root loader
/ clientLoader
. See options to use other route or prop name.
import { loadIs } from "./is";
export const loader = async (args: LoaderFunctionArgs) => {
const is = await loadIs(args);
return {
__is: is.__values,
};
};
ℹ️ The root ErrorBoundary
does not have access to the root loader
data. Since the root Layout
export is shared with the root ErrorBoundary
, if you use <Is>
or useIs
in the Layout
export, consider prefixing all routes with _.
(pathless route) and using ErrorBoundary
in routes/_.tsx
to catch errors before they reach the root ErrorBoundary
.
Using loadIs
import { loadIs } from "./is";
export const loader = (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);
if (!is({ authenticated: true })) {
throw new Response("Unauthorized", {
status: 401,
});
}
if (!is({ role })) {
throw new Response("Forbidden", {
status: 403,
});
}
};
import { authenticated } from "./utils/auth";
export const loader = (args: LoaderFunctionArgs) => {
await authenticated(args, "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(
() => {
const user = { name: "Example" };
return { authenticated: Boolean(user) };
},
{ authenticated: true }
);
Parameters
useValues
: A React hook that acquires and computes the current values
for the comparison logic.- optional
defaultConditions
: The default props/params for Is
and useIs
. - 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.
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) => {
const user = await loadUser(args);
return { authenticated: Boolean(user) };
},
{ authenticated: true }
);
Parameters
loadValues
: A React Router / Remix loader function that acquires and computes the current values
for the comparison logic.- optional
defaultConditions
: The default props/params for Is
, useIs
and is
. - 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"
. The property name in the loader's return value that provides is.__values
. - optional
routeId
: Default: The root route ID ("root"
or "0"
). The route that provides the is.__values
from its loader. Example: "routes/admin"
.
Returns
createFromLoader
returns an array containing the Is
component, the useIs
hook and the loadIs
loader.
<Is>
Props
...conditions
: Conditions are merged with the defaultConditions
and then compared to the useValues
/ loadValues
return 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.method
is "some"
(default), the value array must include at least one of the condition entries. If options.method
is "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.
- 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 the defaultConditions
and then compared to the useValues
/ loadValues
return 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.method
is "some"
(default), the value array must include at least one of the condition entries. If options.method
is "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.
Returns
useIs
returns true
if all conditions match, false
otherwise.
Usage
const isAuthenticated = useIs({ authenticated: true });
const isAuthenticated = useIsAuthenticated();
loadIs
Parameters
args
: React Router / Remix LoaderFunctionArgs
, ActionFunctionArgs
, ClientLoaderFunctionArgs
, or ClientActionFunctionArgs
.
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 the defaultConditions
and then compared to the useValues
/ loadValues
return 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.method
is "some"
(default), the value array must include at least one of the condition entries. If options.method
is "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.
Returns
is
returns a true
if all conditions match, false
otherwise.
Usage
In root.tsx
you must also return is.__values
as __is
from the loader
/ clientLoader
. See options to use other route or prop name.
export const loader = (args: LoaderFunctionArgs) => {
const is = await loadIs(args);
return {
__is: is.__values,
};
};
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);
Parameters
- optional
strings
: An array of strings.
Returns
toBooleanValues
returns an object with true
values.
Types
Value
- Type
Value
is boolean | number | string
. - It may also be more specific, like a union of
string
values.
Example
const features = ["feature-abc", "feature-xyz"] as const;
type Feature = (typeof features)[number];
Values
- Type
Values
is Record<string, Value | Value[]>
.
Example
{
"authenticated": true,
"roles": ["admin"],
"permissions": [
"create-articles",
"read-articles",
"update-articles",
"delete-articles"
]
}
Conditions
- Type
Conditions
is Partial<Values>
.
Example
{
"roles": "admin"
}