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.
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
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.
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:
1import Knock from "@knocklabs/client";
2import { useEffect, useState } from "react";
3const knockClient = new Knock(process.env.KNOCK_PUBLIC_API_KEY);
4knockClient.authenticate(currentUser.id);
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.
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.
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:
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:
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:
1onChange={(e) => {
2 onChange({
3 preferenceKey,
4 preferenceType,
5 channelTypeSettings: {
6 ...channelTypeSettings,
7 [channelType]: e.target.checked,
8 },
9 });
10}}
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:
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:
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:
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 ๐

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