Reactive Settings [WIP]

This post is a work-in-progress!
I'll be working on more-sophisticated settings management as time permits.
2024-11-29

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...

1
2
3
4
function 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...

1
2
3
4
function 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.

1
2
3
4
5
6
7
8
9
import { 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 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.

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
"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?

Let's break it down:

1
useSetting<T extends keyof Settings>(key: T): Settings[T]
  1. <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 our settingsParser:
      • animationDelay
      • sidebarExpanded
      • themePrimary
  2. key: T
    • key is our function argument (or the function input)
    • : T means that our variable key is constrained to the previously narrowed T type
    • In this case, it means key must be one of these string literals:
      • "animationDelay"
      • "sidebarExpanded"
      • "themePrimary"
  3. Settings[T]
    • Settings is the type derived from our settingsParser object
    • [T] is an indexed access type into our Settings type
    • This means that whatever our input value key is, the type we'll get out will match the type on the Settings type for that key:
      • If key is "animationDelay", the output will be a number
      • If key is "sidebarExpanded", the output will be a boolean
      • If key is "themePrimary", the output will be a string

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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import 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.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
"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> ); }