Info:
This is release candidate documentation for Stream Chat React SDK v15. For the latest stable version, see the latest version (v14).

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:

voice-notes-config.ts
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.freeze is enough for a flat configuration; freeze nested groups too.
  • Default a flag the server also controls to true. The two are combined with AND, and true means "no opinion, let the server decide". A false default silently turns off a feature the server allows, and nobody suspects a second switch. Use false only 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:

voice-notes-settings.ts
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:

  1. the defaults;
  2. the app-wide settings;
  3. the options passed to the constructor;
  4. updateConfig() calls;
  5. the server's say, last, so nothing above can go beyond it.
voice-notes.ts
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.
  • configState is the only copy. config reads 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.
  • undefined means "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's uploads setting 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> and ServerUpperBounds<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 true or false with a server true, false or undefined. The undefined server 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>Config type and frozen defaults.
  • Switches the server also controls default to true; booleans are required, not optional.
  • configState, config and updateConfig(), with configState as 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.serverConfig outside 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