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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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