Skip to content

Commit

Permalink
feat: return objects from selectors instead of arrays (#2547)
Browse files Browse the repository at this point in the history
### 🎯 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 <[email protected]>
  • Loading branch information
arnautov-anton and oliverlaz authored Oct 28, 2024
1 parent 8369de8 commit ae2e22a
Show file tree
Hide file tree
Showing 9 changed files with 80 additions and 74 deletions.
54 changes: 28 additions & 26 deletions docusaurus/docs/React/guides/sdk-state-management.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -152,45 +152,45 @@ Selectors are functions provided by integrators that run whenever state object c

#### Rules of Selectors

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

```ts
const selector = (nextValue: ThreadManagerState) => [
nextValue.unreadThreadsCount, // <-- changes often
nextValue.active, // <-- changes less often
nextvalue.lastConnectionDownAt, // <-- changes rarely
];
const selector = (nextValue: ThreadManagerState) => ({
unreadThreadsCount: nextValue.unreadThreadsCount,
active: nextValue.active,
lastConnectionDownAt: nextvalue.lastConnectionDownAt,
});
```

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

```tsx
// ❌ not okay
const Component1 = () => {
const [latestReply] = useThreadState((nextValue: ThreadState) => [
nextValue.latestReplies.at(-1),
]);
const { latestReply } = useThreadState((nextValue: ThreadState) => ({
latestReply: nextValue.latestReplies.at(-1),
}));

return <div>{latestReply.text}</div>;
};

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

const Component2 = () => {
const [latestReply] = useThreadState(selector);
const { latestReply } = useThreadState(selector);

return <div>{latestReply.text}</div>;
};

// ✅ also okay
const Component3 = ({ userId }: { userId: string }) => {
const selector = useCallback(
(nextValue: ThreadState) => [nextValue.read[userId].unread_messages],
(nextValue: ThreadState) => ({ unreadMessagesCount: nextValue.read[userId].unread_messages }),
[userId],
);

const [unreadMessagesCount] = useThreadState(selector);
const { unreadMessagesCount } = useThreadState(selector);

return <div>{unreadMessagesCount}</div>;
};
Expand All @@ -215,9 +215,9 @@ client.threads.state.subscribe(console.log);
let latestThreads;
client.threads.state.subscribeWithSelector(
// called each time theres a change in the state object
(nextValue) => [nextValue.threads],
(nextValue) => ({ threads: nextValue.threads }),
// called only when threads change (selected value)
([threads]) => {
({ threads }) => {
latestThreads = threads;
},
);
Expand All @@ -233,19 +233,19 @@ thread?.state.subscribeWithSelector(/*...*/);
thread?.state.getLatestValue(/*...*/);
```

#### useSimpleStateStore Hook
#### useStateStore Hook

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

```tsx
import { useSimpleStateStore } from 'stream-chat-react';
import { useStateStore } from 'stream-chat-react';
import type { ThreadManagerState } from 'stream-chat';

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

const CustomThreadList = () => {
const { client } = useChatContext();
const [threads] = useSimpleStateStore(client.threads.state, selector);
const { threads } = useStateStore(client.threads.state, selector);

return (
<ul>
Expand All @@ -259,16 +259,18 @@ const CustomThreadList = () => {

#### useThreadState and useThreadManagerState

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

```ts
// memoized or living outside component's scope
const threadStateSelector = (nextValue: ThreadState) => [nextValue.replyCount] as const;
const threadManagerStateSelector = (nextValue: ThreadState) => [nextValue.threads.length] as const;
const threadStateSelector = (nextValue: ThreadState) => ({ replyCount: nextValue.replyCount });
const threadManagerStateSelector = (nextValue: ThreadState) => ({
threadsCount: nextValue.threads.length,
});

const MyComponent = () => {
const [replyCount] = useThreadState(threadStateSelector);
const [threadsCount] = useThreadManagerState(threadManagerStateSelector);
const { replyCount } = useThreadState(threadStateSelector);
const { threadsCount } = useThreadManagerState(threadManagerStateSelector);

return null;
};
Expand Down
6 changes: 4 additions & 2 deletions src/components/ChatView/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,13 @@ const ThreadAdapter = ({ children }: PropsWithChildren) => {
return <ThreadProvider thread={activeThread}>{children}</ThreadProvider>;
};

const selector = (nextValue: ThreadManagerState) => [nextValue.unreadThreadCount];
const selector = ({ unreadThreadCount }: ThreadManagerState) => ({
unreadThreadCount,
});

const ChatViewSelector = () => {
const { client } = useChatContext();
const [unreadThreadCount] = useStateStore(client.threads.state, selector);
const { unreadThreadCount } = useStateStore(client.threads.state, selector);

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

Expand Down
19 changes: 9 additions & 10 deletions src/components/Dialog/hooks/useDialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,21 +20,20 @@ export const useDialog = ({ id }: GetOrCreateDialogParams) => {
export const useDialogIsOpen = (id: string) => {
const { dialogManager } = useDialogManager();
const dialogIsOpenSelector = useCallback(
({ dialogsById }: DialogManagerState) => [!!dialogsById[id]?.isOpen] as const,
({ dialogsById }: DialogManagerState) => ({ isOpen: !!dialogsById[id]?.isOpen }),
[id],
);
return useStateStore(dialogManager.state, dialogIsOpenSelector)[0];
return useStateStore(dialogManager.state, dialogIsOpenSelector).isOpen;
};

const openedDialogCountSelector = (nextValue: DialogManagerState) =>
[
Object.values(nextValue.dialogsById).reduce((count, dialog) => {
if (dialog.isOpen) return count + 1;
return count;
}, 0),
] as const;
const openedDialogCountSelector = (nextValue: DialogManagerState) => ({
openedDialogCount: Object.values(nextValue.dialogsById).reduce((count, dialog) => {
if (dialog.isOpen) return count + 1;
return count;
}, 0),
});

export const useOpenedDialogCount = () => {
const { dialogManager } = useDialogManager();
return useStateStore(dialogManager.state, openedDialogCountSelector)[0];
return useStateStore(dialogManager.state, openedDialogCountSelector).openedDialogCount;
};
19 changes: 9 additions & 10 deletions src/components/Thread/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,12 @@ export const Thread = <
);
};

const selector = (nextValue: ThreadState) =>
[
nextValue.replies,
nextValue.pagination.isLoadingPrev,
nextValue.pagination.isLoadingNext,
nextValue.parentMessage,
] as const;
const selector = (nextValue: ThreadState) => ({
isLoadingNext: nextValue.pagination.isLoadingNext,
isLoadingPrev: nextValue.pagination.isLoadingPrev,
parentMessage: nextValue.parentMessage,
replies: nextValue.replies,
});

const ThreadInner = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
Expand All @@ -102,8 +101,8 @@ const ThreadInner = <
} = props;

const threadInstance = useThreadContext();
const [latestReplies, isLoadingPrev, isLoadingNext, parentMessage] =
useStateStore(threadInstance?.state, selector) ?? [];
const { isLoadingNext, isLoadingPrev, parentMessage, replies } =
useStateStore(threadInstance?.state, selector) ?? {};

const {
thread,
Expand Down Expand Up @@ -154,7 +153,7 @@ const ThreadInner = <
loadingMoreNewer: isLoadingNext,
loadMore: threadInstance.loadPrevPage,
loadMoreNewer: threadInstance.loadNextPage,
messages: latestReplies,
messages: replies,
}
: {
hasMore: threadHasMore,
Expand Down
4 changes: 2 additions & 2 deletions src/components/Threads/ThreadList/ThreadList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ThreadListLoadingIndicator as DefaultThreadListLoadingIndicator } from
import { useChatContext, useComponentContext } from '../../../context';
import { useStateStore } from '../../../store';

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

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

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

useThreadList();

Expand Down
16 changes: 8 additions & 8 deletions src/components/Threads/ThreadList/ThreadListItemUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,18 +72,18 @@ export const ThreadListItemUI = (props: ThreadListItemUIProps) => {
const thread = useThreadListItemContext()!;

const selector = useCallback(
(nextValue: ThreadState) =>
[
nextValue.replies.at(-1),
(nextValue: ThreadState) => ({
channel: nextValue.channel,
deletedAt: nextValue.deletedAt,
latestReply: nextValue.replies.at(-1),
ownUnreadMessageCount:
(client.userID && nextValue.read[client.userID]?.unreadMessageCount) || 0,
nextValue.parentMessage,
nextValue.channel,
nextValue.deletedAt,
] as const,
parentMessage: nextValue.parentMessage,
}),
[client],
);

const [latestReply, ownUnreadMessageCount, parentMessage, channel, deletedAt] = useStateStore(
const { channel, deletedAt, latestReply, ownUnreadMessageCount, parentMessage } = useStateStore(
thread.state,
selector,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@ import { LoadingIndicator as DefaultLoadingIndicator } from '../../Loading';
import { useChatContext, useComponentContext } from '../../../context';
import { useStateStore } from '../../../store';

const selector = (nextValue: ThreadManagerState) => [nextValue.pagination.isLoadingNext];
const selector = (nextValue: ThreadManagerState) => ({
isLoadingNext: nextValue.pagination.isLoadingNext,
});

export const ThreadListLoadingIndicator = () => {
const { LoadingIndicator = DefaultLoadingIndicator } = useComponentContext();
const { client } = useChatContext();
const [isLoadingNext] = useStateStore(client.threads.state, selector);
const { isLoadingNext } = useStateStore(client.threads.state, selector);

if (!isLoadingNext) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { Icon } from '../icons';
import { useChatContext } from '../../../context';
import { useStateStore } from '../../../store';

const selector = (nextValue: ThreadManagerState) => [nextValue.unseenThreadIds] as const;
const selector = (nextValue: ThreadManagerState) => ({
unseenThreadIds: nextValue.unseenThreadIds,
});

export const ThreadListUnseenThreadsBanner = () => {
const { client } = useChatContext();
const [unseenThreadIds] = useStateStore(client.threads.state, selector);
const { unseenThreadIds } = useStateStore(client.threads.state, selector);

if (!unseenThreadIds.length) return null;

Expand Down
24 changes: 12 additions & 12 deletions src/store/hooks/useStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,18 @@ import { useEffect, useState } from 'react';

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

export function useStateStore<T extends Record<string, unknown>, O extends readonly unknown[]>(
store: StateStore<T>,
selector: (v: T) => O,
): O;
export function useStateStore<T extends Record<string, unknown>, O extends readonly unknown[]>(
store: StateStore<T> | undefined,
selector: (v: T) => O,
): O | undefined;
export function useStateStore<T extends Record<string, unknown>, O extends readonly unknown[]>(
store: StateStore<T> | undefined,
selector: (v: T) => O,
) {
export function useStateStore<
T extends Record<string, unknown>,
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>
>(store: StateStore<T>, selector: (v: T) => O): O;
export function useStateStore<
T extends Record<string, unknown>,
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>
>(store: StateStore<T> | undefined, selector: (v: T) => O): O | undefined;
export function useStateStore<
T extends Record<string, unknown>,
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>
>(store: StateStore<T> | undefined, selector: (v: T) => O) {
const [state, setState] = useState<O | undefined>(() => {
if (!store) return undefined;
return selector(store.getLatestValue());
Expand Down

0 comments on commit ae2e22a

Please sign in to comment.