Building preference UIs in React

How to build a complete notification preference center, powered by Knock and React.

In this guide we'll build a PreferenceCenter React component with Knock's preference APIs. This component should be flexible enough to handle most of your needs and can easily be customized or extended for more specific use cases. If you want to reference a TypeScript example, you can find one in the Notion feed example.

Getting started

Before beginning this tutorial, we'd recommend reading the preferences overview docs and creating a default PreferenceSet for your environment. The API reference for preferences can also be helpful, but is not required for this tutorial.

๐Ÿ’ก
A note on merging preferences

Remember that if you have either a environment or tenant default PreferenceSet those preferences will be merged with changes a user makes in the UI, with the user-specified changes taking precedence.

What you'll need

To use this example, you'll need an account on Knock and you'll need to have identified a user. You'll also need:

  • A public API key for the Knock environment (set as KNOCK_PUBLIC_API_KEY)

Installing dependencies

Installing dependencies
1npm install @knocklabs/client

Modeling our preferences

In this example we'll assume the user has a default PreferenceSet that contains workflows and workflow categories, each with it's own channel type settings. We'll expose this to our users as a "cross-hatch" so that they can set a preference for each channel type.

Preference object
1{
2  "id": "default",
3  "categories": {
4    "collaboration": {
5      "channel_types": {
6        "email": true,
7        "in_app_feed": true
8      }
9    },
10    "new-asset": {
11      "channel_types": {
12        "email": false,
13        "in_app_feed": true
14      }
15    }
16  },
17  "workflows": {
18    "new-comment": {
19      "channel_types": {
20        "email": true
21      }
22    }
23  },
24  "channel_types": {}
25}

Creating our preference center

The next step here is to create our preference center component. Create a PreferenceCenter.jsx file in your project and add the following import statements to the top of the file. After that, you'll need to create a new instance of the Knock client and authenticate it against a user:

Import Knock client & React hooks
1import Knock from "@knocklabs/client";
2import { useEffect, useState } from "react";
3const knockClient = new Knock(process.env.KNOCK_PUBLIC_API_KEY);
4knockClient.authenticate(currentUser.id);
1

Create a preferences view config

Next we'll create an configuration object that will help us drive the view of our preference center. In some cases, you may want to store values in a PreferenceSet that you don't directly expose to users or want to provide more descriptive titles, labels, and descriptions.

Power the preference center view
1const PreferenceViewConfig = {
2  RowSettings: {
3    "new-asset": {
4      title: "New Asset",
5      description: "New file uploads in workspaces you're a part of",
6    },
7    "new-comment": {
8      title: "Comments & mentions",
9      description: "New comments and replies to threads.",
10    },
11    collaboration: {
12      title: "In-app messages",
13      description: "Messages from other users on the platform",
14    },
15  },
16  ChannelTypeLabels: {
17    in_app_feed: "In-app Feed",
18    email: "Email",
19    push: "Push",
20  },
21};

In this example, the RowSettings object contains entries that map directly to keys in the PreferenceSet we modeled in the previous step. Each entry here will surface those settings to the user and provide additional human readable details with title and description. If you want to modify this for your own project, you can swap the keys inside of RowSettings with a key from your default PreferenceSet and update the title and description properties.

The ChannelTypeLabels object is similar in that its contents determine which channel type settings will be surfaced for each row. Adding additional entries to this object will present more checkboxes for the user, and you can modify the label value by updating the value of a particular key.

Copy and paste this PreferenceViewConfig object in your component file and make any updates to correspond with the shape of your default PreferenceSet and channels.

2

Display a preference setting row

Next, we'll create a PreferenceSettingsRow component that will display the title, description, and checkbox toggles for each SettingsRow entry:

Display a row for each desired preference setting
1function PreferenceSettingsRow({
2preferenceType,
3preferenceKey,
4channelTypeSettings,
5onChange,
6}) {
7return (
8  <div
9    style={{
10      display: "flex",
11      flexDirection: "row",
12      justifyContent: "space-between",
13      padding: ".75rem .25rem",
14      gap: "1rem",
15    }}
16  >
17    <div>
18      <h2>
19        {PreferenceViewConfig.RowSettings[preferenceKey].title}
20      </h2>
21      <p>
22        {PreferenceViewConfig.RowSettings[preferenceKey].description}
23      </p>
24    </div>
25    <div>
26      {Object.keys(PreferenceViewConfig.ChannelTypeLabels).map(
27        (channelType) => {
28          return (
29            <div
30              key={`${preferenceKey}_${channelType}`}
31              style={{ display: "flex", justifyContent: "space-between" }}
32            >
33              <label htmlFor={`${preferenceKey}_${channelType}`}>
34                {PreferenceViewConfig.ChannelTypeLabels[channelType]}
35              </label>
36              <input
37                id={`${preferenceKey}_${channelType}`}
38                type="checkbox"
39                checked={channelTypeSettings[channelType]}
40                disabled={
41                  typeof channelTypeSettings[channelType] === "undefined"
42                }
43                onChange={(e) => {
44                  onChange({
45                    preferenceKey,
46                    preferenceType,
47                    channelTypeSettings: {
48                      ...channelTypeSettings,
49                      [channelType]: e.target.checked,
50                    },
51                  });
52                }}
53              />
54            </div>
55          );
56        }
57      )}
58    </div>
59  </div>
60);
61}

This component has a lot of functionality built in, so let's unpack what it does.

Using the preferenceKey parameter, this component renders a section of UI that displays the title and description properties stored in the PreferenceViewConfig under the matching key:

Displaying preference details
1<div>
2  <h2>{PreferenceViewConfig.RowSettings[preferenceKey].title}</h2>
3  <p>{PreferenceViewConfig.RowSettings[preferenceKey].description}</p>
4</div>

Next, we'll generate an input element tied to each channel type setting for that preference. We do that by looping through the keys of PreferenceViewConfig.ChannelTypeLabels to generate a UI element tied to a particular channel and preference setting:

1<div>
2  {Object.keys(PreferenceViewConfig.ChannelTypeLabels).map((channelType) => {
3    return (
4      <div
5        key={`${preferenceKey}_${channelType}`}
6        style={{ display: "flex", justifyContent: "space-between" }}
7      >
8        <label htmlFor={`${preferenceKey}_${channelType}`}>
9          {PreferenceViewConfig.ChannelTypeLabels[channelType]}
10        </label>
11        <input
12          id={`${preferenceKey}_${channelType}`}
13          type="checkbox"
14          checked={channelTypeSettings[channelType]}
15          disabled={typeof channelTypeSettings[channelType] === "undefined"}
16          onChange={(e) => {
17            onChange({
18              preferenceKey,
19              preferenceType,
20              channelTypeSettings: {
21                ...channelTypeSettings,
22                [channelType]: e.target.checked,
23              },
24            });
25          }}
26        />
27      </div>
28    );
29  })}
30</div>

This section of UI uses the channelTypeSettings passed into the function to drive the disabled and checked states of the input element. These channelTypeSettings are the user's existing preferences pulled directly from Knock. By disabling the checkbox if those channel type settings are undefined we remove the user's ability to modify that value if it doesn't appear in the default preference set.

As the user toggles the state of this input it fires an onChange event handler that calls a function also passed as a parameter. This function is ultimately what updates the user's preferences in Knock, so we pass a modified value of channelTypeSettings that includes the current value of the event target's checked property:

Updating preferences onChange
1onChange={(e) => {
2  onChange({
3    preferenceKey,
4    preferenceType,
5    channelTypeSettings: {
6      ...channelTypeSettings,
7      [channelType]: e.target.checked,
8    },
9  });
10}}
3

Render your preference center

Now that we have a PreferencesViewConfig object to help us drive the shape of our UI and a PreferenceSettingsRow to render a row's details an the necessary inputs, it's time to compose those elements into an actual PreferenceCenter component. This PreferenceCenter function should be exported:

Compose a preference center
1export default function PreferenceCenter() {
2  //Create some local state to store the user's preferences
3  const [localPreferences, setLocalPreferences] = useState({
4    id: "default",
5    categories: {
6      collaboration: {
7        channel_types: {
8          email: true,
9          in_app_feed: true,
10        },
11      },
12      "new-asset": {
13        channel_types: {
14          email: false,
15          in_app_feed: true,
16        },
17      },
18    },
19    workflows: {
20      "new-comment": {
21        channel_types: {
22          email: true,
23        },
24      },
25    },
26    channel_types: {},
27  });
28
29  //We load the current user's preferences from Knock, and set them to local preferences
30
31  useEffect(() => {
32    async function fetchPreferences() {
33      const preferences = await knockClient.user.getPreferences();
34      setLocalPreferences(preferences);
35    }
36    fetchPreferences();
37  }, [knockClient]);
38
39  //When a preference setting is changed, we create a new PreferenceSet that
40  //includes the change, update the preferences in Knock, and then update local state
41  const onPreferenceChange = async ({
42    preferenceKey,
43    preferenceType,
44    channelTypeSettings,
45  }) => {
46    //create a new preference set with local preferences as starting point
47    const preferenceUpdate = {
48      ...localPreferences,
49    };
50
51    // Here we'll make updates to the preference set based on the preferenceType
52    // and override existing channelTypeSettings
53    // since Workflow and Category preferences can also be a Boolean,
54    // we'll check if the preferenceKey contains a channel_types object
55    if (
56      preferenceType === "category" &&
57      typeof preferenceUpdate.categories[preferenceKey] === "object"
58    ) {
59      preferenceUpdate.categories[preferenceKey].channel_types =
60        channelTypeSettings;
61    }
62    if (
63      preferenceType === "workflow" &&
64      typeof preferenceUpdate.workflows[preferenceKey] === "object"
65    ) {
66      preferenceUpdate.workflows[preferenceKey].channel_types =
67        channelTypeSettings;
68    }
69    //Next, we upload the new PreferenceSet to Knock for that user
70    const preferences = await knockClient.user.setPreferences(preferenceUpdate);
71    // Set the updated preferences in local state
72    setLocalPreferences(preferences);
73  };
74  //If we haven't loaded preferences yet, maybe show a spinner
75  if (!localPreferences) {
76    return null;
77  }
78  return (
79    <div className="preferences">
80      {Object.keys(localPreferences?.categories).map((category) => {
81        return (
82          <PreferenceSettingsRow
83            key={category}
84            preferenceType="category"
85            preferenceKey={category}
86            channelTypeSettings={
87              typeof localPreferences.categories[category] === "object"
88                ? localPreferences?.categories[category]?.channel_types
89                : {}
90            }
91            onChange={onPreferenceChange}
92          ></PreferenceSettingsRow>
93        );
94      })}
95      {Object.keys(localPreferences?.workflows).map((workflow) => {
96        return (
97          <PreferenceSettingsRow
98            key={workflow}
99            preferenceType="workflow"
100            preferenceKey={workflow}
101            channelTypeSettings={
102              typeof localPreferences.workflows[workflow] === "object"
103                ? localPreferences?.workflows[workflow]?.channel_types
104                : {}
105            }
106            onChange={onPreferenceChange}
107          ></PreferenceSettingsRow>
108        );
109      })}
110    </div>
111  );
112}

Let's examine the code in the PreferenceCenter component step-by-step to explain what's happening.

First, we need to load the current user's preferences from Knock and store them in local state so we can operate on them. We can call the getPreferences method on knockClient.user to load a user's preferences:

Store current preferences in local state
1//Create some local state to store the user's preferences
2const [localPreferences, setLocalPreferences] = useState();
3
4//We load the current user's preferences from Knock, and set them to local preferences
5
6useEffect(() => {
7  async function fetchPreferences() {
8    const preferences = await knockClient.user.getPreferences();
9    setLocalPreferences(preferences);
10  }
11  fetchPreferences();
12}, [knockClient]);

Next, we create a function called onPreferenceChange that will get passed as the onChange parameter to our PreferenceSettingsRow component from the previous step. The onPreferenceChange function takes a preferenceKey argument and an updated channelTypeSettings argument:

Update preferences in Knock
1//When a preference setting is changed, we create a new PreferenceSet that
2//includes the change, update the preferences in Knock, and then update local state
3const onPreferenceChange = async ({
4  preferenceKey,
5  preferenceType,
6  channelTypeSettings,
7}) => {
8  //create a new preference set with local preferences as starting point
9  const preferenceUpdate = {
10    ...localPreferences,
11  };
12
13  // Here we'll make updates to the preference set based on the preferenceType
14  // and override existing channelTypeSettings
15  // since Workflow and Category preferences can also be a Boolean,
16  // we'll check if the preferenceKey contains an channel_types object
17  if (
18    preferenceType === "category" &&
19    typeof preferenceUpdate.categories[preferenceKey] === "object"
20  ) {
21    preferenceUpdate.categories[preferenceKey].channel_types =
22      channelTypeSettings;
23  }
24  if (
25    preferenceType === "workflow" &&
26    typeof preferenceUpdate.workflows[preferenceKey] === "object"
27  ) {
28    preferenceUpdate.workflows[preferenceKey].channel_types =
29      channelTypeSettings;
30  }
31  //Next, we upload the new PreferenceSet to Knock for that user
32  const preferences = await knockClient.user.setPreferences(preferenceUpdate);
33  // Set the updated preferences in local state
34  setLocalPreferences(preferences);
35};

Based on the preferenceType of the update, we'll overwrite any existing preferences stored under that key and use the setPreferences method of knockClient.user to update those preferences in Knock. We then set the updated preferences back to local state using setLocalPreferences to keep our UI in sync with what is stored in Knock.

Lastly, we actually render our PreferenceSettingsRow components:

1<div className="preferences">
2  {Object.keys(localPreferences?.categories).map((category) => {
3    return (
4      <PreferenceSettingsRow
5        key={category}
6        preferenceType="category"
7        preferenceKey={category}
8        channelTypeSettings={
9          typeof localPreferences.categories[category] === "object"
10            ? localPreferences?.categories[category]?.channel_types
11            : {}
12        }
13        onChange={onPreferenceChange}
14      ></PreferenceSettingsRow>
15    );
16  })}
17  {Object.keys(localPreferences?.workflows).map((workflow) => {
18    return (
19      <PreferenceSettingsRow
20        key={workflow}
21        preferenceType="workflow"
22        preferenceKey={workflow}
23        channelTypeSettings={
24          typeof localPreferences?.workflows[workflow] === "object"
25            ? localPreferences?.workflows[workflow]?.channel_types
26            : {}
27        }
28        onChange={onPreferenceChange}
29      ></PreferenceSettingsRow>
30    );
31  })}
32</div>

Here we loop through each workflow or category key stored in a user's preferences and pass in the existing channelTypeSettings which will power the state of that row's checkbox inputs and the onPreferenceChange callback to update a user's preferences in Knock.

You should now have a working preference center ๐ŸŽ‰

A basic preference center

Completed preference center

Knock's preference model is very flexible, but you should find that the component below will satisfy most of your preference center needs and can easily be used as a starting point for your own preference center.

Completed preference center
1import Knock from "@knocklabs/client";
2import { useEffect, useState } from "react";
3const knockClient = new Knock(process.env.KNOCK_PUBLIC_API_KEY);
4knockClient.authenticate(currentUser.id);
5
6// Here we create a view config object, this helps us customize the interface
7// and choose which preference options we want to display to the user
8const PreferenceViewConfig: Record<string, any> = {
9  RowSettings: {
10    "new-asset": {
11      title: "New Asset",
12      description: "New file uploads in workspaces you're a part of",
13    },
14    "new-comment": {
15      title: "Comments & mentions",
16      description: "New comments and replies to threads.",
17    },
18    collaboration: {
19      title: "In-app messages",
20      description: "Messages from other users on the platform",
21    },
22  },
23  ChannelTypeLabels: {
24    in_app_feed: "In-app Feed",
25    email: "Email",
26    push: "Push",
27  },
28};
29// The PreferenceSettingsRow component is what actually displays the UI to manipulate
30function PreferenceSettingsRow({
31  preferenceType,
32  preferenceKey,
33  channelTypeSettings,
34  onChange,
35}) {
36  return (
37    <div
38      style={{
39        display: "flex",
40        flexDirection: "row",
41        justifyContent: "space-between",
42        padding: ".75rem .25rem",
43        gap: "1rem",
44      }}
45    >
46      <div>
47        <h2>{PreferenceViewConfig.RowSettings[preferenceKey].title}</h2>
48        <p>{PreferenceViewConfig.RowSettings[preferenceKey].description}</p>
49      </div>
50      <div>
51        {Object.keys(PreferenceViewConfig.ChannelTypeLabels).map(
52          (channelType) => {
53            return (
54              <div
55                key={`${preferenceKey}_${channelType}`}
56                style={{ display: "flex", justifyContent: "space-between" }}
57              >
58                <label htmlFor={`${preferenceKey}_${channelType}`}>
59                  {PreferenceViewConfig.ChannelTypeLabels[channelType]}
60                </label>
61                <input
62                  id={`${preferenceKey}_${channelType}`}
63                  type="checkbox"
64                  checked={channelTypeSettings[channelType]}
65                  disabled={
66                    typeof channelTypeSettings[channelType] === "undefined"
67                  }
68                  onChange={(e) => {
69                    onChange({
70                      preferenceKey,
71                      preferenceType,
72                      channelTypeSettings: {
73                        ...channelTypeSettings,
74                        [channelType]: e.target.checked,
75                      },
76                    });
77                  }}
78                />
79              </div>
80            );
81          },
82        )}
83      </div>
84    </div>
85  );
86}
87
88export default function PreferenceCenter() {
89  //Create some local state to store the user's preferences
90  const [localPreferences, setLocalPreferences] = useState({
91    id: "default",
92    categories: {
93      collaboration: {
94        channel_types: {
95          email: true,
96          in_app_feed: true,
97        },
98      },
99      "new-asset": {
100        channel_types: {
101          email: false,
102          in_app_feed: true,
103        },
104      },
105    },
106    workflows: {
107      "new-comment": {
108        channel_types: {
109          email: true,
110        },
111      },
112    },
113    channel_types: {},
114  });
115
116  //We load the current user's preferences from Knock, and set them to local preferences
117
118  useEffect(() => {
119    async function fetchPreferences() {
120      const preferences = await knockClient.user.getPreferences();
121      setLocalPreferences(preferences);
122    }
123    fetchPreferences();
124  }, [knockClient]);
125
126  //When a preference setting is changed, we create a new PreferenceSet that
127  //includes the change, update the preferences in Knock, and then update local state
128  const onPreferenceChange = async ({
129    preferenceKey,
130    preferenceType,
131    channelTypeSettings,
132  }) => {
133    //create a new preference set with local preferences as starting point
134    const preferenceUpdate = {
135      ...localPreferences,
136    };
137
138    // Here we'll make updates to the preference set based on the preferenceType
139    // and override existing channelTypeSettings
140    // since Workflow and Category preferences can also be a Boolean,
141    // we'll check if the preferenceKey contains an channel_types object
142    if (
143      preferenceType === "category" &&
144      typeof preferenceUpdate.categories[preferenceKey] === "object"
145    ) {
146      preferenceUpdate.categories[preferenceKey].channel_types =
147        channelTypeSettings;
148    }
149    if (
150      preferenceType === "workflow" &&
151      typeof preferenceUpdate.workflows[preferenceKey] === "object"
152    ) {
153      preferenceUpdate.workflows[preferenceKey].channel_types =
154        channelTypeSettings;
155    }
156    //Next, we upload the new PreferenceSet to Knock for that user
157    const preferences = await knockClient.user.setPreferences(preferenceUpdate);
158    // Set the updated preferences in local state
159    setLocalPreferences(preferences);
160  };
161  if (!localPreferences) {
162    return null;
163  }
164  return (
165    <div className="preferences">
166      {Object.keys(localPreferences?.categories).map((category) => {
167        return (
168          <PreferenceSettingsRow
169            key={category}
170            preferenceType="category"
171            preferenceKey={category}
172            channelTypeSettings={
173              typeof localPreferences.categories[category] === "object"
174                ? localPreferences?.categories[category]?.channel_types
175                : {}
176            }
177            onChange={onPreferenceChange}
178          ></PreferenceSettingsRow>
179        );
180      })}
181      {Object.keys(localPreferences?.workflows).map((workflow) => {
182        return (
183          <PreferenceSettingsRow
184            key={workflow}
185            preferenceType="workflow"
186            preferenceKey={workflow}
187            channelTypeSettings={
188              typeof localPreferences?.workflows[workflow] === "object"
189                ? localPreferences?.workflows[workflow]?.channel_types
190                : {}
191            }
192            onChange={onPreferenceChange}
193          ></PreferenceSettingsRow>
194        );
195      })}
196    </div>
197  );
198}