Skip to content

Commit ae2e22a

Browse files
feat: return objects from selectors instead of arrays (#2547)
### 🎯 Goal Adjust selector outputs to be named objects to match the new `StateStore` API. - [x] update `stream-chat` peer dependency to version which comes with this change before merging --------- Co-authored-by: Oliver Lazoroski <oliver.lazoroski@gmail.com>
1 parent 8369de8 commit ae2e22a

File tree

9 files changed

+80
-74
lines changed

9 files changed

+80
-74
lines changed

Diff for: docusaurus/docs/React/guides/sdk-state-management.mdx

+28-26
Original file line numberDiff line numberDiff line change
@@ -152,45 +152,45 @@ Selectors are functions provided by integrators that run whenever state object c
152152

153153
#### Rules of Selectors
154154

155-
1. Selectors should return array of data sorted by their "change factor"; meaning values that change often should come first for the best performance.
155+
1. Selectors should return a named object.
156156

157157
```ts
158-
const selector = (nextValue: ThreadManagerState) => [
159-
nextValue.unreadThreadsCount, // <-- changes often
160-
nextValue.active, // <-- changes less often
161-
nextvalue.lastConnectionDownAt, // <-- changes rarely
162-
];
158+
const selector = (nextValue: ThreadManagerState) => ({
159+
unreadThreadsCount: nextValue.unreadThreadsCount,
160+
active: nextValue.active,
161+
lastConnectionDownAt: nextvalue.lastConnectionDownAt,
162+
});
163163
```
164164

165-
2. Selectors should live outside components scope or should be memoized if it requires "outside" information (`userId` for `read` object for example). Not memoizing selectors (or not stabilizing them) will lead to bad performance as each time your component re-renders, the selector function is created anew and `useSimpleStateStore` goes through unsubscribe and resubscribe process unnecessarily.
165+
2. Selectors should live outside components scope or should be memoized if it requires "outside" information (`userId` for `read` object for example). Not memoizing selectors (or not stabilizing them) will lead to bad performance as each time your component re-renders, the selector function is created anew and `useStateStore` goes through unsubscribe and resubscribe process unnecessarily.
166166

167167
```tsx
168168
// ❌ not okay
169169
const Component1 = () => {
170-
const [latestReply] = useThreadState((nextValue: ThreadState) => [
171-
nextValue.latestReplies.at(-1),
172-
]);
170+
const { latestReply } = useThreadState((nextValue: ThreadState) => ({
171+
latestReply: nextValue.latestReplies.at(-1),
172+
}));
173173

174174
return <div>{latestReply.text}</div>;
175175
};
176176

177177
// ✅ okay
178-
const selector = (nextValue: ThreadState) => [nextValue.latestReplies.at(-1)];
178+
const selector = (nextValue: ThreadState) => ({ latestReply: nextValue.latestReplies.at(-1) });
179179

180180
const Component2 = () => {
181-
const [latestReply] = useThreadState(selector);
181+
const { latestReply } = useThreadState(selector);
182182

183183
return <div>{latestReply.text}</div>;
184184
};
185185

186186
// ✅ also okay
187187
const Component3 = ({ userId }: { userId: string }) => {
188188
const selector = useCallback(
189-
(nextValue: ThreadState) => [nextValue.read[userId].unread_messages],
189+
(nextValue: ThreadState) => ({ unreadMessagesCount: nextValue.read[userId].unread_messages }),
190190
[userId],
191191
);
192192

193-
const [unreadMessagesCount] = useThreadState(selector);
193+
const { unreadMessagesCount } = useThreadState(selector);
194194

195195
return <div>{unreadMessagesCount}</div>;
196196
};
@@ -215,9 +215,9 @@ client.threads.state.subscribe(console.log);
215215
let latestThreads;
216216
client.threads.state.subscribeWithSelector(
217217
// called each time theres a change in the state object
218-
(nextValue) => [nextValue.threads],
218+
(nextValue) => ({ threads: nextValue.threads }),
219219
// called only when threads change (selected value)
220-
([threads]) => {
220+
({ threads }) => {
221221
latestThreads = threads;
222222
},
223223
);
@@ -233,19 +233,19 @@ thread?.state.subscribeWithSelector(/*...*/);
233233
thread?.state.getLatestValue(/*...*/);
234234
```
235235

236-
#### useSimpleStateStore Hook
236+
#### useStateStore Hook
237237

238-
For the ease of use - the React SDK comes with the appropriate state acesss hook which wraps `SimpleStateStore.subscribeWithSelector` API for the React-based applications.
238+
For the ease of use - the React SDK comes with the appropriate state acesss hook which wraps `StateStore.subscribeWithSelector` API for the React-based applications.
239239

240240
```tsx
241-
import { useSimpleStateStore } from 'stream-chat-react';
241+
import { useStateStore } from 'stream-chat-react';
242242
import type { ThreadManagerState } from 'stream-chat';
243243

244-
const selector = (nextValue: ThreadManagerState) => [nextValue.threads] as const;
244+
const selector = (nextValue: ThreadManagerState) => ({ threads: nextValue.threads });
245245

246246
const CustomThreadList = () => {
247247
const { client } = useChatContext();
248-
const [threads] = useSimpleStateStore(client.threads.state, selector);
248+
const { threads } = useStateStore(client.threads.state, selector);
249249

250250
return (
251251
<ul>
@@ -259,16 +259,18 @@ const CustomThreadList = () => {
259259

260260
#### useThreadState and useThreadManagerState
261261

262-
Both of these hooks use `useSimpleStateStore` under the hood but access their respective states through appropriate contexts; for `ThreadManagerState` it's `ChatContext` (accessing `client.threads.state`) and for `ThreadState` it's `ThreadListItemContext` first and `ThreadContext` second meaning that the former is prioritized. While these hooks make it sligthly easier for our integrators to reach reactive state
262+
Both of these hooks use `useStateStore` under the hood but access their respective states through appropriate contexts; for `ThreadManagerState` it's `ChatContext` (accessing `client.threads.state`) and for `ThreadState` it's `ThreadListItemContext` first and `ThreadContext` second meaning that the former is prioritized. While these hooks make it sligthly easier for our integrators to reach reactive state
263263

264264
```ts
265265
// memoized or living outside component's scope
266-
const threadStateSelector = (nextValue: ThreadState) => [nextValue.replyCount] as const;
267-
const threadManagerStateSelector = (nextValue: ThreadState) => [nextValue.threads.length] as const;
266+
const threadStateSelector = (nextValue: ThreadState) => ({ replyCount: nextValue.replyCount });
267+
const threadManagerStateSelector = (nextValue: ThreadState) => ({
268+
threadsCount: nextValue.threads.length,
269+
});
268270

269271
const MyComponent = () => {
270-
const [replyCount] = useThreadState(threadStateSelector);
271-
const [threadsCount] = useThreadManagerState(threadManagerStateSelector);
272+
const { replyCount } = useThreadState(threadStateSelector);
273+
const { threadsCount } = useThreadManagerState(threadManagerStateSelector);
272274

273275
return null;
274276
};

Diff for: src/components/ChatView/ChatView.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,13 @@ const ThreadAdapter = ({ children }: PropsWithChildren) => {
127127
return <ThreadProvider thread={activeThread}>{children}</ThreadProvider>;
128128
};
129129

130-
const selector = (nextValue: ThreadManagerState) => [nextValue.unreadThreadCount];
130+
const selector = ({ unreadThreadCount }: ThreadManagerState) => ({
131+
unreadThreadCount,
132+
});
131133

132134
const ChatViewSelector = () => {
133135
const { client } = useChatContext();
134-
const [unreadThreadCount] = useStateStore(client.threads.state, selector);
136+
const { unreadThreadCount } = useStateStore(client.threads.state, selector);
135137

136138
const { activeChatView, setActiveChatView } = useContext(ChatViewContext);
137139

Diff for: src/components/Dialog/hooks/useDialog.ts

+9-10
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,20 @@ export const useDialog = ({ id }: GetOrCreateDialogParams) => {
2020
export const useDialogIsOpen = (id: string) => {
2121
const { dialogManager } = useDialogManager();
2222
const dialogIsOpenSelector = useCallback(
23-
({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen] as const,
23+
({ dialogsById }: DialogManagerState) => ({ isOpen: !!dialogsById[id]?.isOpen }),
2424
[id],
2525
);
26-
return useStateStore(dialogManager.state, dialogIsOpenSelector)[0];
26+
return useStateStore(dialogManager.state, dialogIsOpenSelector).isOpen;
2727
};
2828

29-
const openedDialogCountSelector = (nextValue: DialogManagerState) =>
30-
[
31-
Object.values(nextValue.dialogsById).reduce((count, dialog) => {
32-
if (dialog.isOpen) return count + 1;
33-
return count;
34-
}, 0),
35-
] as const;
29+
const openedDialogCountSelector = (nextValue: DialogManagerState) => ({
30+
openedDialogCount: Object.values(nextValue.dialogsById).reduce((count, dialog) => {
31+
if (dialog.isOpen) return count + 1;
32+
return count;
33+
}, 0),
34+
});
3635

3736
export const useOpenedDialogCount = () => {
3837
const { dialogManager } = useDialogManager();
39-
return useStateStore(dialogManager.state, openedDialogCountSelector)[0];
38+
return useStateStore(dialogManager.state, openedDialogCountSelector).openedDialogCount;
4039
};

Diff for: src/components/Thread/Thread.tsx

+9-10
Original file line numberDiff line numberDiff line change
@@ -74,13 +74,12 @@ export const Thread = <
7474
);
7575
};
7676

77-
const selector = (nextValue: ThreadState) =>
78-
[
79-
nextValue.replies,
80-
nextValue.pagination.isLoadingPrev,
81-
nextValue.pagination.isLoadingNext,
82-
nextValue.parentMessage,
83-
] as const;
77+
const selector = (nextValue: ThreadState) => ({
78+
isLoadingNext: nextValue.pagination.isLoadingNext,
79+
isLoadingPrev: nextValue.pagination.isLoadingPrev,
80+
parentMessage: nextValue.parentMessage,
81+
replies: nextValue.replies,
82+
});
8483

8584
const ThreadInner = <
8685
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
@@ -102,8 +101,8 @@ const ThreadInner = <
102101
} = props;
103102

104103
const threadInstance = useThreadContext();
105-
const [latestReplies, isLoadingPrev, isLoadingNext, parentMessage] =
106-
useStateStore(threadInstance?.state, selector) ?? [];
104+
const { isLoadingNext, isLoadingPrev, parentMessage, replies } =
105+
useStateStore(threadInstance?.state, selector) ?? {};
107106

108107
const {
109108
thread,
@@ -154,7 +153,7 @@ const ThreadInner = <
154153
loadingMoreNewer: isLoadingNext,
155154
loadMore: threadInstance.loadPrevPage,
156155
loadMoreNewer: threadInstance.loadNextPage,
157-
messages: latestReplies,
156+
messages: replies,
158157
}
159158
: {
160159
hasMore: threadHasMore,

Diff for: src/components/Threads/ThreadList/ThreadList.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from
1010
import { useChatContext, useComponentContext } from '../../../context';
1111
import { useStateStore } from '../../../store';
1212

13-
const selector = (nextValue: ThreadManagerState) => [nextValue.threads] as const;
13+
const selector = (nextValue: ThreadManagerState) => ({ threads: nextValue.threads });
1414

1515
const computeItemKey: ComputeItemKey<Thread, unknown> = (_, item) => item.id;
1616

@@ -49,7 +49,7 @@ export const ThreadList = ({ virtuosoProps }: ThreadListProps) => {
4949
ThreadListLoadingIndicator = DefaultThreadListLoadingIndicator,
5050
ThreadListUnseenThreadsBanner = DefaultThreadListUnseenThreadsBanner,
5151
} = useComponentContext();
52-
const [threads] = useStateStore(client.threads.state, selector);
52+
const { threads } = useStateStore(client.threads.state, selector);
5353

5454
useThreadList();
5555

Diff for: src/components/Threads/ThreadList/ThreadListItemUI.tsx

+8-8
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,18 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => {
7272
const thread = useThreadListItemContext()!;
7373

7474
const selector = useCallback(
75-
(nextValue: ThreadState) =>
76-
[
77-
nextValue.replies.at(-1),
75+
(nextValue: ThreadState) => ({
76+
channel: nextValue.channel,
77+
deletedAt: nextValue.deletedAt,
78+
latestReply: nextValue.replies.at(-1),
79+
ownUnreadMessageCount:
7880
(client.userID && nextValue.read[client.userID]?.unreadMessageCount) || 0,
79-
nextValue.parentMessage,
80-
nextValue.channel,
81-
nextValue.deletedAt,
82-
] as const,
81+
parentMessage: nextValue.parentMessage,
82+
}),
8383
[client],
8484
);
8585

86-
const [latestReply, ownUnreadMessageCount, parentMessage, channel, deletedAt] = useStateStore(
86+
const { channel, deletedAt, latestReply, ownUnreadMessageCount, parentMessage } = useStateStore(
8787
thread.state,
8888
selector,
8989
);

Diff for: src/components/Threads/ThreadList/ThreadListLoadingIndicator.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@ import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading';
66
import { useChatContext, useComponentContext } from '../../../context';
77
import { useStateStore } from '../../../store';
88

9-
const selector = (nextValue: ThreadManagerState) => [nextValue.pagination.isLoadingNext];
9+
const selector = (nextValue: ThreadManagerState) => ({
10+
isLoadingNext: nextValue.pagination.isLoadingNext,
11+
});
1012

1113
export const ThreadListLoadingIndicator = () => {
1214
const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext();
1315
const { client } = useChatContext();
14-
const [isLoadingNext] = useStateStore(client.threads.state, selector);
16+
const { isLoadingNext } = useStateStore(client.threads.state, selector);
1517

1618
if (!isLoadingNext) return null;
1719

Diff for: src/components/Threads/ThreadList/ThreadListUnseenThreadsBanner.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import { Icon } from '../icons';
66
import { useChatContext } from '../../../context';
77
import { useStateStore } from '../../../store';
88

9-
const selector = (nextValue: ThreadManagerState) => [nextValue.unseenThreadIds] as const;
9+
const selector = (nextValue: ThreadManagerState) => ({
10+
unseenThreadIds: nextValue.unseenThreadIds,
11+
});
1012

1113
export const ThreadListUnseenThreadsBanner = () => {
1214
const { client } = useChatContext();
13-
const [unseenThreadIds] = useStateStore(client.threads.state, selector);
15+
const { unseenThreadIds } = useStateStore(client.threads.state, selector);
1416

1517
if (!unseenThreadIds.length) return null;
1618

Diff for: src/store/hooks/useStateStore.ts

+12-12
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@ import { useEffect, useState } from 'react';
22

33
import type { StateStore } from 'stream-chat';
44

5-
export function useStateStore<T extends Record<string, unknown>, O extends readonly unknown[]>(
6-
store: StateStore<T>,
7-
selector: (v: T) => O,
8-
): O;
9-
export function useStateStore<T extends Record<string, unknown>, O extends readonly unknown[]>(
10-
store: StateStore<T> | undefined,
11-
selector: (v: T) => O,
12-
): O | undefined;
13-
export function useStateStore<T extends Record<string, unknown>, O extends readonly unknown[]>(
14-
store: StateStore<T> | undefined,
15-
selector: (v: T) => O,
16-
) {
5+
export function useStateStore<
6+
T extends Record<string, unknown>,
7+
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>
8+
>(store: StateStore<T>, selector: (v: T) => O): O;
9+
export function useStateStore<
10+
T extends Record<string, unknown>,
11+
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>
12+
>(store: StateStore<T> | undefined, selector: (v: T) => O): O | undefined;
13+
export function useStateStore<
14+
T extends Record<string, unknown>,
15+
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>
16+
>(store: StateStore<T> | undefined, selector: (v: T) => O) {
1717
const [state, setState] = useState<O | undefined>(() => {
1818
if (!store) return undefined;
1919
return selector(store.getLatestValue());

0 commit comments

Comments
 (0)