# Suggestions

Suggestions open while the user types a trigger: `@` lists people and groups to mention, and `/` lists commands. The text composer's middleware finds the trigger and the query typed after it, a search source loads the matching items, and picking an item writes it into the text.

>
> **Info:** **Example**
>
> The user types "Thanks @jo" in a channel:
>
> 1. `TextComposer.handleChange()` runs the `onChange` handlers of the text composer's middleware chain. The mentions middleware finds `@jo` just before the cursor.
> 2. It sets `textComposer.state.suggestions` to `{ trigger: "@", query: "jo", searchSource }`.
> 3. When the handlers are done, the composer calls `searchSource.search("jo")`. The search source keeps the results in its own `state`: `items`, `isLoading` and `hasNext`.
> 4. The user picks John. `TextComposer.handleSelect(item)` runs the chain's `onSuggestionItemSelect` handlers: the text becomes "Thanks @John ", John is added to `textComposer.mentions`, and `suggestions` is cleared.
>

## How a suggestion list works

The text composer's middleware chain, `textComposer.middlewareExecutor`, does the work. Each method in the Executes column that has an event runs the handlers for that event in every middleware of the chain, in order. See [Composer middleware](https://getstream.io/chat/docs/sdk/react/state/writing-messages/composer-middleware/).

| Step                                                   | Executes                                            | Event                    | What happens                                                                                                                                                  |
| ------------------------------------------------------ | --------------------------------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1. The user types a trigger and a query, such as `@jo` | `TextComposer.handleChange()`                       | `onChange`               | The trigger's middleware finds `@jo` before the cursor, and sets `textComposer.suggestions` to `{ trigger: "@", query: "jo", searchSource }`: the list opens. |
| 2. The handlers have finished                          | `searchSource.search("jo")`, by the composer        | none                     | The search for "jo" starts. `searchSource.state` gets `isLoading`, then the first page in `items`, and `hasNext`.                                             |
| 3. The user types more, such as `@joh`                 | `TextComposer.handleChange()`                       | `onChange`               | The middleware keeps its search source and sets the new query. A new search starts after a short pause, and `items` is replaced when the new results arrive.  |
| 4. The user scrolls to the end of the list             | `searchSource.search()` without a query, by your UI | none                     | The search for the next page starts, while `hasNext` is true. The page is added to `items`.                                                                   |
| 5a. The user picks an item                             | `TextComposer.handleSelect(item)`                   | `onSuggestionItemSelect` | The middleware writes the item into the text and clears `textComposer.suggestions`: the list closes.                                                          |
| 5b. The user dismisses the list, such as with Escape   | `TextComposer.closeSuggestions()`                   | none                     | `textComposer.suggestions` is cleared. The text stays as typed.                                                                                               |
| 5c. The user deletes the trigger                       | `TextComposer.handleChange()`                       | `onChange`               | The middleware no longer finds the trigger, and clears `textComposer.suggestions`.                                                                            |

A UI follows two stores: the text composer's `state` for whether a list is open, and the open list's `searchSource.state` for its items.

```ts
import type { TextComposer } from "stream-chat";

// Follows the open suggestion list and its items. Returns a function that stops both.
const followSuggestions = (textComposer: TextComposer) => {
  let stopFollowingItems: (() => void) | undefined;

  const stopFollowingList = textComposer.state.subscribeWithSelector(
    // Only the search source: it changes when a list opens, closes or gives way to
    // another trigger's list, not on every keystroke.
    ({ suggestions }) => ({ searchSource: suggestions?.searchSource }),
    ({ searchSource }) => {
      // Stop following the previous list's items, and follow the new list's, if one is open.
      stopFollowingItems?.();
      stopFollowingItems = searchSource?.state.subscribeWithSelector(
        ({ isLoading, items }) => ({ isLoading, items }),
        ({ isLoading, items }) => {
          console.log(
            isLoading ? "Loading…" : `${items?.length ?? 0} suggestions`,
          );
        },
      );
    },
  );

  return () => {
    stopFollowingList();
    stopFollowingItems?.();
  };
};

const stopFollowing = followSuggestions(channel.messageComposer.textComposer);
```

## Built-in triggers

| Trigger | Lists                                                        | Explained on                                                      |
| ------- | ------------------------------------------------------------ | ----------------------------------------------------------------- |
| `@`     | users, `@channel`, `@here`, roles and user groups to mention | [Mentions](https://getstream.io/chat/docs/sdk/react/state/writing-messages/mentions/) |
| `/`     | the channel type's commands, at the start of the text        | [Commands](https://getstream.io/chat/docs/sdk/react/state/writing-messages/commands/) |

## Custom triggers

A new trigger is a text middleware with two handlers: `onChange` opens the suggestions when the trigger is typed, and `onSuggestionItemSelect` writes the picked item into the text. `stream-chat` exports the helpers the built-in middlewares use: `getTriggerCharWithToken()` finds the trigger and the query before the cursor, and `insertItemWithTrigger()` replaces them with the picked item.

This middleware suggests tags after `#`, from a fixed list:

```ts
import type {
  Middleware,
  SearchSourceType,
  TextComposerMiddleware,
  TextComposerMiddlewareExecutorState,
  TextComposerSuggestion,
} from "stream-chat";
import {
  BaseSearchSourceSync,
  getTriggerCharWithToken,
  insertItemWithTrigger,
} from "stream-chat";

type TagSuggestion = TextComposerSuggestion<{ name: string }>;

const TAGS = ["design", "release", "support"];

// A search source that answers locally, without requests.
class TagSearchSource extends BaseSearchSourceSync<TagSuggestion> {
  readonly type: SearchSourceType = "tags";

  constructor() {
    // Search with an empty query too, so `#` alone lists every tag.
    super({ allowEmptySearchString: true });
  }

  // One page of matches. `next: null` means there's no further page.
  protected query(searchQuery: string) {
    const items = TAGS.filter((tag) => tag.startsWith(searchQuery)).map(
      (tag) => ({ id: tag, name: tag }),
    );
    return { items, next: null };
  }

  protected filterQueryResults(items: TagSuggestion[]) {
    return items;
  }
}

const createTagMiddleware = (): Middleware<
  TextComposerMiddlewareExecutorState<TagSuggestion>,
  "onChange" | "onSuggestionItemSelect"
> => {
  const trigger = "#";
  const searchSource = new TagSearchSource();
  // A search source runs queries only while it's active.
  searchSource.activate();

  return {
    id: "my-app/tags-middleware",
    handlers: {
      onChange: ({ complete, forward, next, state }) => {
        if (!state.selection) return forward();

        // The trigger and the query typed after it, before the cursor, such as `#rel`,
        // or null. With `acceptTrailingSpaces: false`, a space ends the query.
        const triggerWithQuery = getTriggerCharWithToken({
          acceptTrailingSpaces: false,
          text: state.text.slice(0, state.selection.end),
          trigger,
        });

        if (!triggerWithQuery) {
          // Close the list this middleware opened, once the trigger is gone.
          if (state.suggestions?.trigger !== trigger) return forward();
          return next({ ...state, suggestions: undefined });
        }

        // Only `#` so far: a new search, so drop the previous results.
        if (triggerWithQuery === trigger) searchSource.resetStateAndActivate();

        // Open the list. The composer then calls `searchSource.search(query)`. Completing
        // stops the chain, so the middleware after this one doesn't run for this change.
        return complete({
          ...state,
          suggestions: {
            query: triggerWithQuery.slice(trigger.length),
            searchSource,
            trigger,
          },
        });
      },
      onSuggestionItemSelect: ({ complete, forward, state }) => {
        const selected = state.change?.selectedSuggestion;
        // Not a pick from this middleware's list: leave it to the others.
        if (!selected || state.suggestions?.trigger !== trigger)
          return forward();

        // Replace the typed `#rel` with `#release `, and close the list.
        return complete({
          ...state,
          ...insertItemWithTrigger({
            insertText: `#${selected.name} `,
            selection: state.selection,
            text: state.text,
            trigger,
          }),
          suggestions: undefined,
        });
      },
    },
  };
};

client.config.setSetupFunction("messageComposer", ({ composer }) => {
  // The chain is typed for the built-in suggestions. The cast lets in a middleware
  // with a suggestion type of its own.
  const tags = createTagMiddleware() as unknown as TextComposerMiddleware;

  composer.textComposer.middlewareExecutor.insert({
    middleware: [tags],
    // Before the mentions and commands middleware: while its list is open, they don't run.
    position: { before: "stream-io/text-composer/mentions-middleware" },
    unique: true,
  });

  return () => composer.textComposer.middlewareExecutor.remove(tags.id);
});
```

The middleware completes the chain while its list is open, so the mentions and commands middlewares don't run for that change. [Emoji](https://getstream.io/chat/docs/sdk/react/features/rich-content/emoji/) shows the emoji middleware, which works the same way with `:` as its trigger, and [Custom suggestions](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/custom-suggestions/) adds the UI for a trigger of your own.

## Pitfalls

- **Two middlewares, one trigger.** The first middleware in the chain that completes wins. Pick a trigger the built-in ones don't use, or put your middleware in place of theirs.
- **A search source per composer.** Create the search source inside the setup function, as the example does, so composers don't share results.

## Used in

- [Emoji](https://getstream.io/chat/docs/sdk/react/features/rich-content/emoji/)
- [Custom suggestions](https://getstream.io/chat/docs/sdk/react/ui-cookbooks/composer/custom-suggestions/)

---

For the most recent version of this documentation, visit [https://getstream.io/chat/docs/sdk/react/state/writing-messages/suggestions/](https://getstream.io/chat/docs/sdk/react/state/writing-messages/suggestions/).