Use global state in MoreComponents for better forward/back UX

This commit is contained in:
Sam Becker 2024-01-15 23:54:08 -06:00
parent d2f1e6a38c
commit 0d892aad12
10 changed files with 158 additions and 42 deletions

View File

@ -3,7 +3,7 @@ import { SpeedInsights } from '@vercel/speed-insights/react';
import { clsx } from 'clsx/lite'; import { clsx } from 'clsx/lite';
import { IBM_Plex_Mono } from 'next/font/google'; import { IBM_Plex_Mono } from 'next/font/google';
import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config'; import { BASE_URL, SITE_DESCRIPTION, SITE_TITLE } from '@/site/config';
import StateProvider from '@/state/AppStateProvider'; import AppStateProvider from '@/state/AppStateProvider';
import ThemeProviderClient from '@/site/ThemeProviderClient'; import ThemeProviderClient from '@/site/ThemeProviderClient';
import Nav from '@/site/Nav'; import Nav from '@/site/Nav';
import ToasterWithThemes from '@/toast/ToasterWithThemes'; import ToasterWithThemes from '@/toast/ToasterWithThemes';
@ -13,6 +13,7 @@ import { Suspense } from 'react';
import FooterClient from '@/site/FooterClient'; import FooterClient from '@/site/FooterClient';
import NavClient from '@/site/NavClient'; import NavClient from '@/site/NavClient';
import { Metadata } from 'next/types'; import { Metadata } from 'next/types';
import MoreComponentsProvider from '@/state/MoreComponentsProvider';
import '../site/globals.css'; import '../site/globals.css';
@ -72,27 +73,29 @@ export default function RootLayout({
suppressHydrationWarning suppressHydrationWarning
> >
<body className={ibmPlexMono.variable}> <body className={ibmPlexMono.variable}>
<StateProvider> <AppStateProvider>
<ThemeProviderClient> <MoreComponentsProvider>
<main className={clsx( <ThemeProviderClient>
'mx-3 mb-3', <main className={clsx(
'lg:mx-6 lg:mb-6', 'mx-3 mb-3',
)}> 'lg:mx-6 lg:mb-6',
<Suspense fallback={<NavClient />}>
<Nav />
</Suspense>
<div className={clsx(
'min-h-[16rem] sm:min-h-[30rem]',
'mb-12',
)}> )}>
{children} <Suspense fallback={<NavClient />}>
</div> <Nav />
<Suspense fallback={<FooterClient />}> </Suspense>
<Footer /> <div className={clsx(
</Suspense> 'min-h-[16rem] sm:min-h-[30rem]',
</main> 'mb-12',
</ThemeProviderClient> )}>
</StateProvider> {children}
</div>
<Suspense fallback={<FooterClient />}>
<Footer />
</Suspense>
</main>
</ThemeProviderClient>
</MoreComponentsProvider>
</AppStateProvider>
<Analytics /> <Analytics />
<SpeedInsights /> <SpeedInsights />
<PhotoEscapeHandler /> <PhotoEscapeHandler />

View File

@ -2,7 +2,7 @@
import { ReactNode, useRef } from 'react'; import { ReactNode, useRef } from 'react';
import { Variant, motion } from 'framer-motion'; import { Variant, motion } from 'framer-motion';
import { useAppState } from '@/state'; import { useAppState } from '@/state/AppState';
import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion'; import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom'; export type AnimationType = 'none' | 'scale' | 'left' | 'right' | 'bottom';

View File

@ -1,14 +1,20 @@
'use client'; 'use client';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef } from 'react';
import Spinner from './Spinner'; import Spinner from './Spinner';
import SiteGrid from './SiteGrid'; import SiteGrid from './SiteGrid';
import {
MoreComponentsKey,
MoreComponentsStateForKeyArgument,
useMoreComponentsState,
} from '@/state/MoreComponentsState';
const MAX_ATTEMPTS_PER_REQUEST = 5; const MAX_ATTEMPTS_PER_REQUEST = 5;
const MAX_TOTAL_REQUESTS = 500; const MAX_TOTAL_REQUESTS = 500;
const RETRY_DELAY_IN_SECONDS = 1.5; const RETRY_DELAY_IN_SECONDS = 1.5;
export default function MoreComponents({ export default function MoreComponents({
stateKey,
initialOffset, initialOffset,
itemsPerRequest, itemsPerRequest,
getNextComponent, getNextComponent,
@ -16,6 +22,7 @@ export default function MoreComponents({
triggerOnView = true, triggerOnView = true,
prefetch = true, prefetch = true,
}: { }: {
stateKey: MoreComponentsKey
initialOffset: number initialOffset: number
itemsPerRequest: number itemsPerRequest: number
getNextComponent: (offset: number, limit: number) => Promise<{ getNextComponent: (offset: number, limit: number) => Promise<{
@ -27,11 +34,20 @@ export default function MoreComponents({
triggerOnView?: boolean triggerOnView?: boolean
prefetch?: boolean prefetch?: boolean
}) { }) {
const [indexToView, setIndexToView] = useState(0); const { state, setStateForKey } = useMoreComponentsState();
const [indexLoaded, setIndexLoaded] = useState(0);
const [isLoading, setIsLoading] = useState(false); const setState = useCallback(
const [lastIndexToLoad, setLastIndexToLoad] = useState<number>(); (stateForKey: MoreComponentsStateForKeyArgument) =>
const [components, setComponents] = useState<JSX.Element[]>([]); setStateForKey(stateKey, stateForKey),
[setStateForKey, stateKey]);
const {
indexToView,
indexLoaded,
isLoading,
lastIndexToLoad,
components,
} = state[stateKey];
// When prefetching, always stay one request ahead of what's visible // When prefetching, always stay one request ahead of what's visible
const indexToLoad = lastIndexToLoad const indexToLoad = lastIndexToLoad
@ -53,7 +69,7 @@ export default function MoreComponents({
if (totalRequests.current < MAX_TOTAL_REQUESTS) { if (totalRequests.current < MAX_TOTAL_REQUESTS) {
attemptsPerRequest.current += 1; attemptsPerRequest.current += 1;
totalRequests.current += 1; totalRequests.current += 1;
setIsLoading(true); setState({ isLoading: true });
const handleError = () => { const handleError = () => {
setTimeout(() => { setTimeout(() => {
attempt(); attempt();
@ -65,14 +81,17 @@ export default function MoreComponents({
) )
.then(({ nextComponent, isFinished, didFail }) => { .then(({ nextComponent, isFinished, didFail }) => {
if (!didFail && nextComponent) { if (!didFail && nextComponent) {
setComponents(current => { setState(state => {
const updatedComponents = [...current]; const updatedComponents = [...state.components];
updatedComponents[indexToLoad] = nextComponent; updatedComponents[indexToLoad] = nextComponent;
return updatedComponents; return {
...state,
components: updatedComponents,
indexLoaded: indexToLoad,
};
}); });
setIndexLoaded(indexToLoad);
if (isFinished) { if (isFinished) {
setLastIndexToLoad(indexToLoad); setState({ lastIndexToLoad: indexToLoad });
} }
attemptsPerRequest.current = 0; attemptsPerRequest.current = 0;
} else { } else {
@ -80,7 +99,7 @@ export default function MoreComponents({
} }
}) })
.catch(handleError) .catch(handleError)
.finally(() => setIsLoading(false)); .finally(() => setState({ isLoading: false }));
} else { } else {
console.error( console.error(
`Max total attempts reached (${MAX_TOTAL_REQUESTS})` `Max total attempts reached (${MAX_TOTAL_REQUESTS})`
@ -92,9 +111,10 @@ export default function MoreComponents({
); );
} }
}, [ }, [
setState,
getNextComponent, getNextComponent,
indexToLoad,
initialOffset, initialOffset,
indexToLoad,
itemsPerRequest, itemsPerRequest,
]); ]);
@ -118,9 +138,9 @@ export default function MoreComponents({
const advance = useCallback(() => { const advance = useCallback(() => {
if (indexToView <= indexLoaded) { if (indexToView <= indexLoaded) {
setIndexToView(i => i + 1); setState({ indexToView: indexToView + 1 });
} }
}, [indexToView, indexLoaded]); }, [setState, indexToView, indexLoaded]);
useEffect(() => { useEffect(() => {
// Only add observer if button is rendered // Only add observer if button is rendered

View File

@ -13,6 +13,7 @@ export function MorePhotosLarge({
}) { }) {
return ( return (
<MoreComponents <MoreComponents
stateKey="PhotosLarge"
label="More photos" label="More photos"
initialOffset={initialOffset} initialOffset={initialOffset}
itemsPerRequest={itemsPerRequest} itemsPerRequest={itemsPerRequest}

View File

@ -4,7 +4,7 @@ import { ReactNode } from 'react';
import { Photo } from '@/photo'; import { Photo } from '@/photo';
import Link from 'next/link'; import Link from 'next/link';
import { AnimationConfig } from '../components/AnimateItems'; import { AnimationConfig } from '../components/AnimateItems';
import { useAppState } from '@/state'; import { useAppState } from '@/state/AppState';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';

View File

@ -5,7 +5,7 @@ import { Photo, getNextPhoto, getPreviousPhoto } from '@/photo';
import PhotoLink from './PhotoLink'; import PhotoLink from './PhotoLink';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { pathForPhoto } from '@/site/paths'; import { pathForPhoto } from '@/site/paths';
import { useAppState } from '@/state'; import { useAppState } from '@/state/AppState';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
import { Camera } from '@/camera'; import { Camera } from '@/camera';
import { FilmSimulation } from '@/simulation'; import { FilmSimulation } from '@/simulation';

View File

@ -1,11 +1,11 @@
'use client'; 'use client';
import { useState, useEffect, ReactNode } from 'react'; import { useState, useEffect, ReactNode } from 'react';
import { AppStateContext } from '.'; import { AppStateContext } from './AppState';
import { AnimationConfig } from '@/components/AnimateItems'; import { AnimationConfig } from '@/components/AnimateItems';
import usePathnames from '@/utility/usePathnames'; import usePathnames from '@/utility/usePathnames';
export default function StateProvider({ export default function AppStateProvider({
children, children,
}: { }: {
children: ReactNode children: ReactNode

View File

@ -0,0 +1,42 @@
'use client';
import { ReactNode, useCallback, useState } from 'react';
import {
MoreComponentsContext,
MoreComponentsKey,
MoreComponentsState,
MORE_COMPONENTS_INITIAL_STATE,
MoreComponentsStateForKeyArgument,
} from './MoreComponentsState';
export default function MoreComponentsProvider({
children,
}: {
children: ReactNode
}) {
const [state, setState] =
useState<MoreComponentsState>(MORE_COMPONENTS_INITIAL_STATE);
const setStateForKey = useCallback((
key: MoreComponentsKey,
state: MoreComponentsStateForKeyArgument
) => {
setState(existingState => ({
...existingState,
...typeof state === 'function'
? { [key]: state(existingState[key]) }
: { [key]: { ...existingState[key], ...state } },
}));
}, []);
return (
<MoreComponentsContext.Provider
value={{
state,
setStateForKey,
}}
>
{children}
</MoreComponentsContext.Provider>
);
}

View File

@ -0,0 +1,50 @@
import { createContext, useContext } from 'react';
export type MoreComponentsKey =
'PhotosLarge';
export interface MoreComponentsStateForKey {
indexToView: number
indexLoaded: number
isLoading: boolean
lastIndexToLoad?: number
components: JSX.Element[]
}
export const createInitialStateForKey =
(): MoreComponentsStateForKey => ({
indexToView: 0,
indexLoaded: 0,
isLoading: false,
components: [],
});
export type MoreComponentsState = Record<
MoreComponentsKey,
MoreComponentsStateForKey
>;
export type MoreComponentsStateForKeyArgument =
Partial<MoreComponentsStateForKey> |
((existingValue: MoreComponentsStateForKey) => MoreComponentsStateForKey);
export interface MoreComponentsContext {
state: MoreComponentsState
setStateForKey: (
key: MoreComponentsKey,
state: MoreComponentsStateForKeyArgument,
) => void
}
export const MORE_COMPONENTS_INITIAL_STATE: MoreComponentsState = {
PhotosLarge: createInitialStateForKey(),
};
export const MoreComponentsContext =
createContext<MoreComponentsContext>({
state: MORE_COMPONENTS_INITIAL_STATE,
setStateForKey: () => {},
});
export const useMoreComponentsState = () =>
useContext(MoreComponentsContext);