Advanced: making your own class configurable
client.config configures the SDK's own objects, and only those. Its keys are fixed: channel, thread, messageComposer, messagePaginator, messageOperations, client, liveLocationManager and searchController. InstanceConfigTree and InstanceSetupFunctionArgs are type aliases, not interfaces, so module augmentation can't add a key, and client.config.setConfig("voiceNotes", ...) doesn't compile. The code the SDK uses to resolve and apply settings isn't exported either.
A class of your own is configured by your own code. This page shows how to give it the same shape as the SDK's configurable objects, so the code that reads it works the same way, and how to let the server narrow it with the helper stream-chat exports. To configure the SDK's own objects, see Configuration.
The example is a VoiceNotes class that holds the settings for recording voice notes in a channel: whether they're offered, the longest recording, and the longest caption.
The contract
Configurable objects in the SDK expose the same members, and code that reads settings relies on that shape. Every one has configState and config, and all but a channel also have updateConfig(). Give your class all three:
| Member | Type | What it is |
|---|---|---|
configState |
StateStore<TConfig> |
The settings in effect, as a store to subscribe to. |
config |
Readonly<TConfig> |
The same settings, read once. |
updateConfig(patch) |
(Partial<TConfig>) => void |
Changes the settings of this one object. |
config must always be the whole answer: defaults, app-wide settings, constructor options, updateConfig() calls and the server's say, already combined. Code that reads it should never have to combine it with something else, such as a server flag, to know whether a feature is on. Getting this wrong is the most common mistake with configurable objects.
Step 1: Define the configuration and its defaults
Name the type after the class, <Class>Config, as the SDK does with MessageComposerConfig and ChannelConfig:
export type VoiceNotesConfig = {
/** Whether voice notes are offered. The channel type's `uploads` setting can turn them off. */
enabled: boolean;
/** The longest recording, in milliseconds. */
maxDurationMs: number;
/** The longest caption. The channel type's `max_message_length` caps it. */
maxCaptionLength?: number;
};
export const DEFAULT_VOICE_NOTES_CONFIG: Readonly<VoiceNotesConfig> =
Object.freeze({
enabled: true,
maxDurationMs: 2 * 60 * 1000,
});Three rules apply to defaults:
- Freeze them. Every resolved configuration is spread from the defaults, so a part no layer sets is the defaults object itself, reachable through
config. A write through it would change the default for every instance. Frozen, the write fails instead.Object.freezeis enough for a flat configuration; freeze nested groups too. - Default a flag the server also controls to
true. The two are combined with AND, andtruemeans "no opinion, let the server decide". Afalsedefault silently turns off a feature the server allows, and nobody suspects a second switch. Usefalseonly for a feature the server has no say in, which is off until the app turns it on. - Prefer required booleans with a default over optional ones. An optional flag's "off" is easy to confuse with "not set".
Step 2: Keep app-wide settings in one place
client.config holds the settings for every instance of a kind. Do the same for your class with a store of your own:
import { StateStore } from "@stream-io/state-store";
import type { VoiceNotesConfig } from "./voice-notes-config";
/** Settings for every VoiceNotes instance, the existing ones and the ones created later. */
export const voiceNotesSettings = new StateStore<Partial<VoiceNotesConfig>>({});
export const setVoiceNotesSettings = (settings: Partial<VoiceNotesConfig>) => {
voiceNotesSettings.next({
...voiceNotesSettings.getLatestValue(),
...settings,
});
};StateStore comes from @stream-io/state-store, the package stream-chat keeps its own stores in. Add it to your app's dependencies to import it, in the range stream-chat asks for (^1.1.6), so the app installs one copy.
An app then sets the settings once, wherever it creates the client: setVoiceNotesSettings({ maxDurationMs: 60_000 }).
Step 3: Resolve the settings in a store
The class builds its settings from layers, the same way the SDK's objects do. A later layer wins for the fields it sets:
- the defaults;
- the app-wide settings;
- the options passed to the constructor;
updateConfig()calls;- the server's say, last, so nothing above can go beyond it.
import { StateStore } from "@stream-io/state-store";
import { mergeServerRestrictions } from "stream-chat";
import type {
Channel,
ServerRestrictions,
ServerUpperBounds,
} from "stream-chat";
import {
DEFAULT_VOICE_NOTES_CONFIG,
type VoiceNotesConfig,
} from "./voice-notes-config";
import { voiceNotesSettings } from "./voice-notes-settings";
const isSameConfig = (a: VoiceNotesConfig, b: VoiceNotesConfig) =>
a.enabled === b.enabled &&
a.maxDurationMs === b.maxDurationMs &&
a.maxCaptionLength === b.maxCaptionLength;
export class VoiceNotes {
readonly configState: StateStore<VoiceNotesConfig>;
private patches: Partial<VoiceNotesConfig> = {};
private readonly unsubscribers: Array<() => void>;
constructor(
private readonly channel: Channel,
private readonly options: Partial<VoiceNotesConfig> = {},
) {
this.configState = new StateStore<VoiceNotesConfig>(this.resolve());
this.unsubscribers = [
// The app-wide settings changed.
voiceNotesSettings.subscribe(() => this.rederive()),
// The channel's first server response brings the channel type's settings.
channel.state.subscribeWithSelector(
({ initialized }) => ({ initialized }),
() => this.rederive(),
),
];
}
get config(): Readonly<VoiceNotesConfig> {
return this.configState.getLatestValue();
}
updateConfig(patch: Partial<VoiceNotesConfig>) {
this.patches = { ...this.patches, ...patch };
this.rederive();
}
dispose() {
this.unsubscribers.forEach((unsubscribe) => unsubscribe());
}
/** The server's switches. Read on every derivation, so a change is picked up. */
private get serverRestrictions(): ServerRestrictions<VoiceNotesConfig> {
return { enabled: this.channel.serverConfig?.uploads };
}
/** The server's limits. */
private get serverUpperBounds(): ServerUpperBounds<VoiceNotesConfig> {
return { maxCaptionLength: this.channel.serverConfig?.max_message_length };
}
private resolve(): VoiceNotesConfig {
const requested: VoiceNotesConfig = {
...DEFAULT_VOICE_NOTES_CONFIG,
...voiceNotesSettings.getLatestValue(),
...this.options,
...this.patches,
};
return mergeServerRestrictions(
requested,
this.serverRestrictions,
this.serverUpperBounds,
);
}
private rederive() {
const next = this.resolve();
if (!isSameConfig(next, this.config)) this.configState.next(next);
}
}A few choices in it matter:
- Every change rebuilds from all the layers.
VoiceNotes.updateConfig()records the request and rebuilds, and so does a change of the app-wide settings or of the server's answer. Writing a patch straight into the store instead would be lost at the next rebuild, or would outlive a server "no". - A rebuild that changes nothing publishes nothing. Each rebuild makes a new object, so the store would notify its subscribers every time without the comparison.
configStateis the only copy.configreads it, so the two can't drift apart.VoiceNotes.dispose()releases the subscriptions. Without it, the app-wide store keeps every instance alive.
Step 4: Let the server narrow it
Do this only if a server-side setting has a say in your feature. mergeServerRestrictions(requested, restrictions, upperBounds?), exported from stream-chat, applies the server's say under the rules the SDK's own objects follow:
import { mergeServerRestrictions } from "stream-chat";
const resolved = mergeServerRestrictions(
{ enabled: true, maxCaptionLength: 5000, maxDurationMs: 60_000 },
{ enabled: false },
{ maxCaptionLength: 2000 },
);
// { enabled: false, maxCaptionLength: 2000, maxDurationMs: 60_000 }- Booleans are ANDed. Either side can turn a feature off, and neither can turn it on alone. A switch the client turned off stays off where the server allows it, and the other way round.
undefinedmeans "the server says nothing". The request stands. This is how you say that the server has no say, for example when the files go to your own storage, so Stream'suploadssetting doesn't apply. It's also the value of every server setting before the channel has been queried.- Limits go in
upperBounds. A restriction replaces the requested value, which for a limit would widen a stricter request. An upper bound keeps the smaller of the two, and applies alone when nothing was requested.ServerRestrictions<TConfig>andServerUpperBounds<TConfig>type the two arguments.
The channel type's settings arrive with the channel, in channel.serverConfig, which is undefined until the channel has been queried or watched. An instance built before that holds the defaults, so it has to rebuild once the answer is there. VoiceNotes rebuilds when channel.state reports initialized, which the first server response sets after storing the settings. Configuration lists the settings the server sends.
Rules that are easy to get wrong
Never read the raw server setting outside the restriction getters. Once a switch is combined, channel.serverConfig?.uploads is only the server's half of the answer. Everything else reads config. Offering a feature that the client had already turned off is the bug this prevents.
Don't guard setters on the value in effect.
// Wrong: `this.config.enabled` already includes the server's say
set enabled(next: boolean) {
if (next === this.config.enabled) return;
this.updateConfig({ enabled: next });
}Where the server turned the feature off, the value in effect never moves. The guard then skips recording a request, and an older request comes back the moment the server allows the feature again. Drop the guard: the rebuild already skips publishing when nothing changed.
Re-apply values that are read once. A setting that feeds something built once, such as a timer interval or a debounce, doesn't change by itself when the setting does. Subscribe to configState and rebuild that part, or document the setting as read at construction.
Name the server side serverConfig, never config. channel.config holds the settings in effect, and channel.serverConfig the channel type's server settings. Keep the same split in your own classes.
Tests worth writing
- The defaults are frozen. A write to a field of the defaults throws in strict mode.
- The layers win in order. The app-wide settings beat the defaults, a constructor option beats the app-wide settings, and
updateConfig()beats the constructor. - A write that changes nothing publishes nothing. Subscribe, call
updateConfig()with the current value, and check that the subscriber didn't run again. - Every client and server combination. For each switch the server controls, check every pair of requested
trueorfalsewith a servertrue,falseorundefined. Theundefinedserver case is the one that catches "no opinion" being treated as "no". - Each limit. A larger request gets the server's limit, a smaller one keeps its own, and no request gets the server's.
- A late server answer rebuilds the settings. Create the instance before the channel is queried, then query it.
When you fix a bug in this area, undo the fix once and check that the new test fails.
Checklist
- A
<Class>Configtype and frozen defaults. - Switches the server also controls default to
true; booleans are required, not optional. -
configState,configandupdateConfig(), withconfigStateas the only copy. - App-wide settings in one store, and every instance rebuilds when they change.
- Every change rebuilds from all the layers, and a rebuild that changes nothing publishes nothing.
-
mergeServerRestrictions()if the server has a say, with limits as upper bounds, and a rebuild once the channel's settings arrive. - No reads of
channel.serverConfigoutside the restriction getters. - No setters guarded on the value in effect.
- A
dispose()that releases every subscription. - The tests above.
Classes inside stream-chat
A configurable class that ships in stream-chat itself is wired with the package's internal machinery, which isn't exported: a ConfigController resolves its layers and applies the server's say, applyInstanceConfiguration subscribes each instance to its key, and the key is added to InstanceConfigTree and INSTANCE_CONFIG_TREE_SHAPE. That work happens in the stream-chat repository, whose instance configuration guide describes it.
See also
- Configuration:
client.config, setup functions, the resolution order and the server settings. - Configuration tree: every setting of the SDK's own objects.
- Reactive state: stores and subscriptions.