Simplify selecting zustand store state

This commit is contained in:
schroda
2026-03-11 22:10:34 +01:00
parent 72af9d52d1
commit 1cccd1ea46
24 changed files with 154 additions and 135 deletions

View File

@@ -31,10 +31,7 @@ const BaseReaderAutoScroll = ({
themeDirection: Direction;
combinedDirection: Direction;
}) => {
const { scrollRef, direction } = useReaderAutoScrollStore((state) => ({
scrollRef: state.scrollRef,
direction: state.direction,
}));
const { scrollRef, direction } = useReaderAutoScrollStore('scrollRef', 'direction');
const { readingMode, autoScroll } = useReaderSettingsStore((state) => ({
readingMode: state.readingMode.value,
autoScroll: state.autoScroll,

View File

@@ -11,9 +11,7 @@ import type { IReaderSettings } from '@/features/reader/Reader.types.ts';
import { getReaderAutoScrollStore, useReaderAutoScrollStore } from '@/features/reader/stores/ReaderStore.ts';
export const useReaderAutoScroll = (isOverlayVisible: boolean, isStaticNav: IReaderSettings['isStaticNav']): void => {
const { isPaused } = useReaderAutoScrollStore((state) => ({
isPaused: state.isPaused,
}));
const isPaused = useReaderAutoScrollStore('isPaused');
useEffect(() => {
const { pause, resume } = getReaderAutoScrollStore();

View File

@@ -28,7 +28,7 @@ export const ReaderNavBarDesktopAutoScroll = ({
setAutoScroll: (newAutoScroll: IReaderSettings['autoScroll'], commit: boolean) => void;
}) => {
const { t } = useLingui();
const isActive = useReaderAutoScrollStore((state) => state.isActive);
const isActive = useReaderAutoScrollStore('isActive');
const updateTimeout = useRef<NodeJS.Timeout>(undefined);

View File

@@ -66,9 +66,7 @@ export const ReaderHotkeys = ({
}) => {
const readerThemeDirection = ReaderService.useGetThemeDirection();
const { enableScope, disableScope } = useHotkeysContext();
const { hotkeys } = useReaderSettingsStore((state) => ({
hotkeys: state.hotkeys,
}));
const hotkeys = useReaderSettingsStore('hotkeys');
const exitReader = ReaderService.useExit();
useHotkeys(hotkeys[ReaderHotkey.PREVIOUS_PAGE], () => ReaderControls.openPage('previous'));

View File

@@ -23,7 +23,7 @@ const BaseReaderOverlay = ({
isDesktop,
isMobile,
}: Pick<ReturnType<typeof ReaderService.useOverlayMode>, 'isDesktop' | 'isMobile'>) => {
const isVisible = useReaderOverlayStore((state) => state.isVisible);
const isVisible = useReaderOverlayStore('isVisible');
const [areSettingsOpen, setAreSettingsOpen] = useState(false);

View File

@@ -30,17 +30,13 @@ const BaseReaderPageNumber = ({
}: Pick<ReturnType<typeof ReaderService.useOverlayMode>, 'isDesktop'> &
Pick<NavbarContextType, 'readerNavBarWidth'>) => {
const scrollbar = useReaderScrollbarStore((state) => state);
const { currentPageIndex, pages, totalPages } = useReaderPagesStore((state) => ({
currentPageIndex: state.currentPageIndex,
pages: state.pages,
totalPages: state.totalPages,
}));
const { currentPageIndex, pages, totalPages } = useReaderPagesStore('currentPageIndex', 'pages', 'totalPages');
const { readingDirection, shouldShowPageNumber, progressBarType } = useReaderSettingsStore((state) => ({
readingDirection: state.readingDirection.value,
shouldShowPageNumber: state.shouldShowPageNumber,
progressBarType: state.progressBarType,
}));
const isMaximized = useReaderProgressBarStore((state) => state.isMaximized);
const isMaximized = useReaderProgressBarStore('isMaximized');
const pageName = useMemo(() => {
const currentPageName = getPage(currentPageIndex, pages).name;

View File

@@ -18,9 +18,7 @@ export const useReaderHideOverlayOnUserScroll = (
isOverlayVisible: boolean,
scrollElementRef: MutableRefObject<HTMLDivElement | null>,
) => {
const { showPreview } = useReaderTapZoneStore((state) => ({
showPreview: state.showPreview,
}));
const showPreview = useReaderTapZoneStore('showPreview');
useEffect(() => {
const handleScroll = () => {

View File

@@ -42,9 +42,9 @@ const DEFAULT_MANGA = { ...FALLBACK_MANGA, title: '' };
const BaseReaderOverlayHeaderMobile = ({ isVisible, ref }: MobileHeaderProps & { ref?: Ref<HTMLDivElement> }) => {
const { t } = useLingui();
const popupState = usePopupState({ popupId: 'reader-overlay-more-menu', variant: 'popover' });
const currentChapter = useReaderChaptersStore((state) => state.currentChapter);
const currentChapter = useReaderChaptersStore('currentChapter');
const manga = useReaderStore((state) => state.manga);
const manga = useReaderStore('manga');
const scrollbar = useReaderScrollbarStore((state) => state);
const { id: mangaId, title } = manga ?? DEFAULT_MANGA;

View File

@@ -23,7 +23,7 @@ const ACTION_FALLBACK_MANGA = {
};
export const ReaderLibraryButton = memo(() => {
const manga = useReaderStore((state) => state.manga);
const manga = useReaderStore('manga');
const { inLibrary } = manga ?? ACTION_FALLBACK_MANGA;

View File

@@ -59,7 +59,7 @@ const BaseReaderNavBarDesktop = ({
setReaderNavBarWidth,
}: ReaderNavBarDesktopProps & Pick<NavbarContextType, 'setReaderNavBarWidth'>) => {
const { t } = useLingui();
const manga = useReaderStore((state) => state.manga);
const manga = useReaderStore('manga');
const {
chapters,
currentChapterId,
@@ -77,7 +77,7 @@ const BaseReaderNavBarDesktop = ({
previousChapter: state.previousChapter,
nextChapter: state.nextChapter,
}));
const isStaticNav = useReaderSettingsStore((state) => state.isStaticNav);
const isStaticNav = useReaderSettingsStore('isStaticNav');
const [navBarElement, setNavBarElement] = useState<HTMLDivElement | null>();
useResizeObserver(

View File

@@ -72,7 +72,7 @@ export const ReaderNavBarDesktopActions = memo(() => {
}));
const { t } = useLingui();
const pageLoadStates = useReaderPagesStore((state) => state.pageLoadStates);
const pageLoadStates = useReaderPagesStore('pageLoadStates');
const pageRetryKeyPrefix = useRef<number>(0);

View File

@@ -23,10 +23,7 @@ import { ReaderControls } from '@/features/reader/services/ReaderControls.ts';
const BaseReaderNavBarDesktopPageNavigation = () => {
const { t } = useLingui();
const getOptionForDirection = useGetOptionForDirection();
const { currentPageIndex, pages } = useReaderPagesStore((state) => ({
currentPageIndex: state.currentPageIndex,
pages: state.pages,
}));
const { currentPageIndex, pages } = useReaderPagesStore('currentPageIndex', 'pages');
const readingDirection = useReaderSettingsStore((state) => state.readingDirection.value);
const currentPage = useMemo(() => getPage(currentPageIndex, pages), [currentPageIndex, pages]);

View File

@@ -23,14 +23,14 @@ import { useReaderSettingsStore } from '@/features/reader/stores/ReaderStore.ts'
const BaseReaderNavBarDesktopQuickSettings = ({ openSettings }: Pick<ReaderNavBarDesktopProps, 'openSettings'>) => {
const { t } = useLingui();
const { readingMode, shouldOffsetDoubleSpreads, pageScaleMode, shouldStretchPage, readingDirection, autoScroll } =
useReaderSettingsStore((state) => ({
readingMode: state.readingMode,
shouldOffsetDoubleSpreads: state.shouldOffsetDoubleSpreads,
pageScaleMode: state.pageScaleMode,
shouldStretchPage: state.shouldStretchPage,
readingDirection: state.readingDirection,
autoScroll: state.autoScroll,
}));
useReaderSettingsStore(
'readingMode',
'shouldOffsetDoubleSpreads',
'pageScaleMode',
'shouldStretchPage',
'readingDirection',
'autoScroll',
);
return (
<Stack sx={{ gap: 1 }}>

View File

@@ -23,12 +23,12 @@ import {
const BaseReaderBottomBarMobileQuickSettings = () => {
const { t } = useLingui();
const isActive = useReaderAutoScrollStore((state) => state.isActive);
const { readingMode, readingDirection, autoScroll } = useReaderSettingsStore((state) => ({
readingMode: state.readingMode,
readingDirection: state.readingDirection,
autoScroll: state.autoScroll,
}));
const isActive = useReaderAutoScrollStore('isActive');
const { readingMode, readingDirection, autoScroll } = useReaderSettingsStore(
'readingMode',
'readingDirection',
'autoScroll',
);
return (
<Stack sx={{ gap: 2 }}>

View File

@@ -68,14 +68,14 @@ const BaseReaderProgressBar = ({
direction: ReturnType<typeof ReaderService.useGetThemeDirection>;
fullSegmentClicks: boolean;
}) => {
const { pages, pageLoadStates, totalPages, currentPageIndex } = useReaderPagesStore((state) => ({
pages: state.pages,
pageLoadStates: state.pageLoadStates,
totalPages: state.totalPages,
currentPageIndex: state.currentPageIndex,
}));
const { pages, pageLoadStates, totalPages, currentPageIndex } = useReaderPagesStore(
'pages',
'pageLoadStates',
'totalPages',
'currentPageIndex',
);
const readingMode = useReaderSettingsStore((state) => state.readingMode.value);
const isDragging = useReaderProgressBarStore((state) => state.isDragging);
const isDragging = useReaderProgressBarStore('isDragging');
const progressBarRef = useRef<HTMLDivElement | null>(null);
const draggingDetectionTimeout = useRef<NodeJS.Timeout>(undefined);

View File

@@ -37,18 +37,15 @@ const BaseStandardReaderProgressBar = ({
const theme = useTheme();
const scrollbar = useReaderScrollbarStore((state) => state);
const totalPages = useReaderPagesStore((state) => state.totalPages);
const totalPages = useReaderPagesStore('totalPages');
const { progressBarType, progressBarSize, progressBarPosition, progressBarPositionAutoVertical } =
useReaderSettingsStore((state) => ({
progressBarType: state.progressBarType,
progressBarSize: state.progressBarSize,
progressBarPosition: state.progressBarPosition,
progressBarPositionAutoVertical: state.progressBarPositionAutoVertical,
}));
const { isMaximized, isDragging } = useReaderProgressBarStore((state) => ({
isMaximized: state.isMaximized,
isDragging: state.isDragging,
}));
useReaderSettingsStore(
'progressBarType',
'progressBarSize',
'progressBarPosition',
'progressBarPositionAutoVertical',
);
const { isMaximized, isDragging } = useReaderProgressBarStore('isMaximized', 'isDragging');
const [, setRefreshProgressBarPosition] = useState({});
useResizeObserver(

View File

@@ -59,20 +59,14 @@ const BaseMobileReaderProgressBar = ({
bottomOffset?: number;
}) => {
const scrollbar = useReaderScrollbarStore((state) => state);
const isVisible = useReaderOverlayStore((state) => state.isVisible);
const { currentPageIndex, pages } = useReaderPagesStore((state) => ({
currentPageIndex: state.currentPageIndex,
pages: state.pages,
}));
const { previousChapter, nextChapter } = useReaderChaptersStore((state) => ({
previousChapter: state.previousChapter,
nextChapter: state.nextChapter,
}));
const { progressBarPosition, progressBarPositionAutoVertical } = useReaderSettingsStore((state) => ({
progressBarPosition: state.progressBarPosition,
progressBarPositionAutoVertical: state.progressBarPositionAutoVertical,
}));
const isDragging = useReaderProgressBarStore((state) => state.isDragging);
const isVisible = useReaderOverlayStore('isVisible');
const { currentPageIndex, pages } = useReaderPagesStore('currentPageIndex', 'pages');
const { previousChapter, nextChapter } = useReaderChaptersStore('previousChapter', 'nextChapter');
const { progressBarPosition, progressBarPositionAutoVertical } = useReaderSettingsStore(
'progressBarPosition',
'progressBarPositionAutoVertical',
);
const isDragging = useReaderProgressBarStore('isDragging');
const [, setRefreshProgressBarPosition] = useState({});
useResizeObserver(

View File

@@ -49,14 +49,12 @@ const BaseReader = ({
readerNavBarWidth,
}: Pick<NavbarContextType, 'setOverride' | 'readerNavBarWidth'>) => {
const { t } = useLingui();
const manga = useReaderStore((state) => state.manga);
const manga = useReaderStore('manga');
const { mangaChapters, initialChapter, chapterForDuplicatesHandling, currentChapter } = useReaderChaptersStore(
(state) => ({
mangaChapters: state.mangaChapters,
initialChapter: state.initialChapter,
chapterForDuplicatesHandling: state.chapterForDuplicatesHandling,
currentChapter: state.currentChapter,
}),
'mangaChapters',
'initialChapter',
'chapterForDuplicatesHandling',
'currentChapter',
);
const {
shouldSkipDupChapters,
@@ -67,16 +65,16 @@ const BaseReader = ({
tapZoneInvertMode,
shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview,
} = useReaderSettingsStore((state) => ({
shouldSkipDupChapters: state.shouldSkipDupChapters,
shouldSkipFilteredChapters: state.shouldSkipFilteredChapters,
backgroundColor: state.backgroundColor,
readingMode: state.readingMode,
tapZoneLayout: state.tapZoneLayout,
tapZoneInvertMode: state.tapZoneInvertMode,
shouldShowReadingModePreview: state.shouldShowReadingModePreview,
shouldShowTapZoneLayoutPreview: state.shouldShowTapZoneLayoutPreview,
}));
} = useReaderSettingsStore(
'shouldSkipDupChapters',
'shouldSkipFilteredChapters',
'backgroundColor',
'readingMode',
'tapZoneLayout',
'tapZoneInvertMode',
'shouldShowReadingModePreview',
'shouldShowTapZoneLayoutPreview',
);
const scrollElementRef = useRef<HTMLDivElement | null>(null);

View File

@@ -425,7 +425,7 @@ export class ReaderService {
static useOverlayMode(): { mode: ReaderOverlayMode; isDesktop: boolean; isMobile: boolean } {
const isTouchDevice = MediaQuery.useIsTouchDevice();
const overlayMode = useReaderSettingsStore((state) => state.overlayMode);
const overlayMode = useReaderSettingsStore('overlayMode');
const isAutoModeSelected = overlayMode === ReaderOverlayMode.AUTO;
const isDesktopModeSelected = overlayMode === ReaderOverlayMode.DESKTOP;
@@ -442,7 +442,7 @@ export class ReaderService {
}
static useExit(): () => void {
const exitMode = useReaderSettingsStore((state) => state.exitMode);
const exitMode = useReaderSettingsStore('exitMode');
const handleBack = useBackButton();
const navigate = useNavigate();

View File

@@ -9,7 +9,6 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useShallow } from 'zustand/react/shallow';
import type { TMangaReader } from '@/features/manga/Manga.types.ts';
import type { ReaderOverlayStoreSlice } from '@/features/reader/overlay/ReaderOverlayStore.ts';
import { createReaderOverlayStoreSlice } from '@/features/reader/overlay/ReaderOverlayStore.ts';
@@ -156,37 +155,29 @@ const readerStore = create<ReaderStore>()(
},
),
);
export const useReaderStore = <T>(selector: (state: ReaderStore) => T): T => readerStore(useShallow(selector));
export const useReaderStore = ZustandUtil.createStoreHook(readerStore);
export const getReaderStore = () => readerStore.getState();
export const useReaderScrollbarStore = <T>(selector: (state: ReaderStore['scrollbar']) => T): T =>
readerStore(useShallow((state) => selector(state.scrollbar)));
export const useReaderScrollbarStore = ZustandUtil.createStoreHook(readerStore, 'scrollbar');
export const getReaderScrollbarStore = () => readerStore.getState().scrollbar;
export const useReaderSettingsStore = <T>(selector: (state: ReaderStore['settings']) => T): T =>
readerStore(useShallow((state) => selector(state.settings)));
export const useReaderSettingsStore = ZustandUtil.createStoreHook(readerStore, 'settings');
export const getReaderSettingsStore = () => readerStore.getState().settings;
export const useReaderOverlayStore = <T>(selector: (state: ReaderOverlayStoreSlice['overlay']) => T): T =>
readerStore(useShallow((state) => selector(state.overlay)));
export const useReaderOverlayStore = ZustandUtil.createStoreHook(readerStore, 'overlay');
export const getReaderOverlayStore = () => readerStore.getState().overlay;
export const useReaderAutoScrollStore = <T>(selector: (state: ReaderAutoScrollStoreSlice['autoScroll']) => T): T =>
readerStore(useShallow((state) => selector(state.autoScroll)));
export const useReaderAutoScrollStore = ZustandUtil.createStoreHook(readerStore, 'autoScroll');
export const getReaderAutoScrollStore = () => readerStore.getState().autoScroll;
export const useReaderPagesStore = <T>(selector: (state: ReaderPagesStoreSlice['pages']) => T): T =>
readerStore(useShallow((state) => selector(state.pages)));
export const useReaderPagesStore = ZustandUtil.createStoreHook(readerStore, 'pages');
export const getReaderPagesStore = () => readerStore.getState().pages;
export const useReaderChaptersStore = <T>(selector: (state: ReaderChaptersStoreSlice['chapters']) => T): T =>
readerStore(useShallow((state) => selector(state.chapters)));
export const useReaderChaptersStore = ZustandUtil.createStoreHook(readerStore, 'chapters');
export const getReaderChaptersStore = () => readerStore.getState().chapters;
export const useReaderProgressBarStore = <T>(selector: (state: ReaderProgressBarStoreSlice['progressBar']) => T): T =>
readerStore(useShallow((state) => selector(state.progressBar)));
export const useReaderProgressBarStore = ZustandUtil.createStoreHook(readerStore, 'progressBar');
export const getReaderProgressBarStore = () => readerStore.getState().progressBar;
export const useReaderTapZoneStore = <T>(selector: (state: ReaderTapZoneStoreSlice['tapZone']) => T): T =>
readerStore(useShallow((state) => selector(state.tapZone)));
export const useReaderTapZoneStore = ZustandUtil.createStoreHook(readerStore, 'tapZone');
export const getReaderTapZoneStore = () => readerStore.getState().tapZone;

View File

@@ -26,7 +26,7 @@ const BaseTapZoneLayout = ({ readerNavBarWidth }: Pick<NavbarContextType, 'reade
tapZoneInvertMode: state.tapZoneInvertMode.value,
readingDirection: state.readingDirection.value,
}));
const showPreview = useReaderTapZoneStore((state) => state.showPreview);
const showPreview = useReaderTapZoneStore('showPreview');
const [width, setWidth] = useState(0);
const [height, setHeight] = useState(0);

View File

@@ -68,24 +68,22 @@ const BaseReaderViewer = ({
ref?: ForwardedRef<HTMLDivElement | null>;
}) => {
const { direction: themeDirection } = useTheme();
const isOverlayVisible = useReaderOverlayStore((state) => state.isVisible);
const isOverlayVisible = useReaderOverlayStore('isVisible');
const { currentPageIndex, pageToScrollToIndex, pages, totalPages, transitionPageMode, retryFailedPagesKeyPrefix } =
useReaderPagesStore((state) => ({
currentPageIndex: state.currentPageIndex,
pageToScrollToIndex: state.pageToScrollToIndex,
pages: state.pages,
totalPages: state.totalPages,
transitionPageMode: state.transitionPageMode,
retryFailedPagesKeyPrefix: state.retryFailedPagesKeyPrefix,
}));
useReaderPagesStore(
'currentPageIndex',
'pageToScrollToIndex',
'pages',
'totalPages',
'transitionPageMode',
'retryFailedPagesKeyPrefix',
);
const { initialChapter, currentChapter, chapters, visibleChapters, isCurrentChapterReady } = useReaderChaptersStore(
(state) => ({
initialChapter: state.initialChapter,
currentChapter: state.currentChapter,
chapters: state.chapters,
visibleChapters: state.visibleChapters,
isCurrentChapterReady: state.isCurrentChapterReady,
}),
'initialChapter',
'currentChapter',
'chapters',
'visibleChapters',
'isCurrentChapterReady',
);
const {
readingMode,

View File

@@ -99,9 +99,9 @@ const BaseReaderTransitionPage = ({
handleBack: () => void;
}) => {
const { t } = useLingui();
const manga = useReaderStore((state) => state.manga);
const manga = useReaderStore('manga');
const scrollbar = useReaderScrollbarStore((state) => state);
const transitionPageMode = useReaderPagesStore((state) => state.transitionPageMode);
const transitionPageMode = useReaderPagesStore('transitionPageMode');
const { readingMode, backgroundColor, shouldShowTransitionPage } = useReaderSettingsStore((state) => ({
readingMode: state.readingMode.value,
backgroundColor: state.backgroundColor,
@@ -226,7 +226,7 @@ export const ReaderTransitionPage = withPropsFrom(
memo(BaseReaderTransitionPage) as typeof BaseReaderTransitionPage,
[
({ chapterId }: Pick<ComponentProps<typeof BaseReaderTransitionPage>, 'chapterId'>) => {
const chapters = useReaderChaptersStore((state) => state.chapters);
const chapters = useReaderChaptersStore('chapters');
const currentChapterIndex = useMemo(
() => chapters.findIndex((chapter) => chapter.id === chapterId),
@@ -249,7 +249,7 @@ export const ReaderTransitionPage = withPropsFrom(
useNavBarContext,
({ chapterId, type }: Pick<ComponentProps<typeof BaseReaderTransitionPage>, 'chapterId' | 'type'>) => {
const handleBack = useBackButton();
const chapters = useReaderChaptersStore((state) => state.chapters);
const chapters = useReaderChaptersStore('chapters');
const currentChapterIndex = useMemo(
() => chapters.findIndex((chapter) => chapter.id === chapterId),

View File

@@ -6,7 +6,64 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import { useShallow } from 'zustand/react/shallow';
type UseBoundStoreAny<S> = <T>(selector: (state: S) => T) => T;
type StoreHook<State> = {
<T>(selector: (state: State) => T): T;
<Key extends keyof State>(key: Key): State[Key];
<Key extends keyof State>(...keys: Key[]): Pick<State, Key>;
};
export class ZustandUtil {
static createKeySelector<State, Key extends keyof State>(keys: Key[]): (state: State) => Pick<State, Key> {
return (state) => {
const result = {} as Pick<State, Key>;
for (const key of keys) {
result[key] = state[key];
}
return result;
};
}
static createStoreHook<FullState extends object, SliceKey extends keyof FullState>(
store: UseBoundStoreAny<FullState>,
sliceKey: SliceKey,
): StoreHook<FullState[SliceKey]>;
static createStoreHook<State>(store: UseBoundStoreAny<State>): StoreHook<State>;
static createStoreHook<State>(
store: UseBoundStoreAny<State>,
sliceKey?: keyof State,
): StoreHook<State[keyof State] | State> {
return ((...args: unknown[]) => {
let selector: (state: State) => unknown;
if (typeof args[0] === 'function') {
const fn = args[0] as (state: unknown) => unknown;
selector = sliceKey !== undefined ? (state: State) => fn(state[sliceKey]) : fn;
} else if (args.length === 1) {
const key = args[0];
selector =
sliceKey !== undefined
? (state: State) => (state[sliceKey] as Record<PropertyKey, unknown>)[key as PropertyKey]
: (state: State) => (state as Record<PropertyKey, unknown>)[key as PropertyKey];
} else {
const pickSelector = ZustandUtil.createKeySelector<Record<PropertyKey, unknown>, PropertyKey>(
args as PropertyKey[],
);
selector =
sliceKey !== undefined
? (state: State) => pickSelector(state[sliceKey] as Record<PropertyKey, unknown>)
: (pickSelector as (state: State) => unknown);
}
return store(useShallow(selector));
}) as StoreHook<State[keyof State] | State>;
}
static createActionName(...names: string[]) {
const storeAndSlices = names.slice(0, -1);
const action = names.slice(-1)[0];