Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: return objects from selectors instead of arrays #2547

Merged
merged 4 commits into from
Oct 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading