Reactive Settings [WIP]
How I'm building settings into my server-side rendered apps (i.e. apps built with the Next.js App Router).
What are we actually trying to accomplish here?
TypeScript is good and you should be using it. There are some obvious ways to make settings type-safe, such as making every setting have the same type, but those have some obvious limitations.
If a setting uses the incorrect type, we should know ASAP. This means that we should see the issue within our IDE and/or at build time, NOT just at runtime.
This may seem fairly obvious (we are building React apps after all), but it's worth stating nonetheless.
We should be able to write code like this...
1234function Component() {
const value = useSetting("my-setting-identifier");
...
}
...where value
always contains the most up-to-date state of the setting.
This one is a little less obvious than the above requirement, but think of this as the useRef
hook that React provides.
We should be able to write code like this...
1234function Component() {
const valueRef = useSettingRef("my-setting-identifier");
...
}
...where valueRef.current
always contains the most up-to-date state of the setting, but changes to the underlying setting value do NOT cause our Component
to re-render.
The React code that powers this whole thing!
If we want our settings to allow for a variety of types but still remain type-safe, we need to define these settings and their types in our app.
This uses zod
for parsing and validating our settings. If we load settings from an external system that isn't quite as type-safe as our app, we need the runtime parsing and validation that zod
provides.
123456789import { z } from "zod";
export const settingsParser = z.object({
animationDelay: z.coerce.number().catch(100),
sidebarExpanded: z.coerce.boolean().catch(true),
themePrimary: z.coerce.string().catch("red"),
});
export type Settings = z.infer<typeof settingsParser>;
What's up with the z.coerce
and .catch(...)
?
z.coerce
and .catch(...)
?z.coerce
will attempt to "coerce" any inputted value into the following primitive type. This allows us to load in settings data from an untrusted source while still validating the data type.
.catch(...)
will provide a fallback value for our setting if the validation fails for whatever reason. Validation may fail if we pass a value that cannot be coerced (ex: passing the string "hello" as a number) or if a value isn't present (ex: the user has never used the app before and doesn't have any established settings yet).
This works to provide both build-time and runtime type-safety into our app surrounding settings.
My IDE correctly throws an error when I create a 'settings' object with missing values.
We want a centralized place settings are managed, which provides the necessary hooks for the rest of our components.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091"use client";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Settings } from "./definitions";
const SettingsContext = createContext<Settings | undefined>(undefined);
type UpdateSetting = <T extends keyof Settings>(key: T, value: Settings[T]) => void;
const UpdateSettingContext = createContext<UpdateSetting | undefined>(undefined);
export function SettingsProvider(props: { initialSettings: Settings; children: React.ReactNode }) {
/**************************************************************************/
/* State */
const [settings, setSettings] = useState<Settings>(props.initialSettings);
const updateSetting = useCallback(
<T extends keyof Settings>(key: T, value: Settings[T]) => {
// Update local state
setSettings((prevState) => {
return {
...prevState,
[key]: value,
};
});
// Save setting change somewhere externally
...
},
[setSettings]
) satisfies UpdateSetting;
/**************************************************************************/
/* Render */
return (
<SettingsContext.Provider value={settings}>
<UpdateSettingContext.Provider value={updateSetting}>
{props.children}
</UpdateSettingContext.Provider>
</SettingsContext.Provider>
);
}
// Use this when you want a setting change to re-render components
export function useSetting<T extends keyof Settings>(key: T): Settings[T] {
const context = useContext(SettingsContext);
if (!context) {
throw new Error("Missing SettingsProvider context provider.");
}
return useMemo(() => context[key], [context, key]);
}
// Use this when you don't want a setting change to re-render components
export function useSettingRef<T extends keyof Settings>(
key: T
): React.MutableRefObject<Settings[T]> {
const context = useContext(SettingsContext);
if (!context) {
throw new Error("Missing SettingsProvider context provider.");
}
const valueRef = useRef(context[key]);
useEffect(() => {
valueRef.current = context[key];
}, [context, key]);
return valueRef;
}
export function useUpdateSetting() {
const context = useContext(UpdateSettingContext);
if (!context) {
throw new Error("Missing SettingsProvider context provider.");
}
return context;
}
What's up with the types on the useSetting
hook?
useSetting
hook?Let's break it down:
1useSetting<T extends keyof Settings>(key: T): Settings[T]
<T extends keyof Settings>
- This makes our
useSetting
function a generic function - In this case, it means that the output type of our function depends on the input type
- Now, when we reference the type
T
anywhere in our function, we know that variable is constrained to be a key of oursettingsParser
:animationDelay
sidebarExpanded
themePrimary
- This makes our
key: T
key
is our function argument (or the function input): T
means that our variablekey
is constrained to the previously narrowedT
type- In this case, it means
key
must be one of these string literals:"animationDelay"
"sidebarExpanded"
"themePrimary"
Settings[T]
Settings
is the type derived from oursettingsParser
object[T]
is an indexed access type into ourSettings
type- This means that whatever our input value
key
is, the type we'll get out will match the type on theSettings
type for thatkey
:- If
key
is"animationDelay"
, the output will be anumber
- If
key
is"sidebarExpanded"
, the output will be aboolean
- If
key
is"themePrimary"
, the output will be astring
- If
All of this gives us type-safety for our useSetting
, useSettingRef
, and useUpdateSetting
hooks.
Make sure you get your settings EARLY in your application, before almost anything else loads.
If you're using Next.js, this will almost certainly opt you into dynamically-rendered pages.
1234567891011121314151617181920212223import React from "react";
import { fetchSettings } from "@components/settings/fetch-settings";
import { SettingsProvider } from "@components/settings/provider";
export default async function RootLayout(props: { children: React.ReactNode }) {
/**************************************************************************/
/* Fetch data */
const initialSettings = await fetchSettings();
/**************************************************************************/
/* Render */
return (
<html lang="en">
<body>
<SettingsProvider initialSettings={initialSettings}>
{props.children}
</SettingsProvider>
</body>
</html>
);
}
Using the settings hooks should be relatively straightforward.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758"use client";
import { z } from "zod";
import {
Label,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@fletcheaston/ui";
import { useSetting, useUpdateSetting } from "@components/settings/provider";
export function ModifySettings() {
/**************************************************************************/
/* State */
const updateSetting = useUpdateSetting();
const animationDelay = useSetting("animationDelay");
/**************************************************************************/
/* Render */
return (
<div className="flex flex-col gap-8">
<div>
<Label>Animation Delay</Label>
<Select
value={animationDelay.toString()}
onValueChange={(rawValue) => {
const value = z.coerce.number().parse(rawValue);
updateSetting("animationDelay", value);
}}
>
<SelectTrigger>
<SelectValue placeholder="Animation Delay" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="0">No Delay (May Cause Lag)</SelectItem>
<SelectItem value="1">Minimum Delay (1ms)</SelectItem>
<SelectItem value="10">Very Low Delay (10ms)</SelectItem>
<SelectItem value="100">Low Delay (100ms)</SelectItem>
<SelectItem value="500">Moderate Delay (500ms)</SelectItem>
<SelectItem value="1000">Large Delay (1,000ms)</SelectItem>
<SelectItem value="5000">Very Large Delay (5,000ms)</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
);
}