Use global state in MoreComponents for better forward/back UX
This commit is contained in:
parent
d2f1e6a38c
commit
0d892aad12
@ -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 />
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -13,6 +13,7 @@ export function MorePhotosLarge({
|
||||
}) {
|
||||
return (
|
||||
<MoreComponents
|
||||
stateKey="PhotosLarge"
|
||||
label="More photos"
|
||||
initialOffset={initialOffset}
|
||||
itemsPerRequest={itemsPerRequest}
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
42
src/state/MoreComponentsProvider.tsx
Normal file
42
src/state/MoreComponentsProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/state/MoreComponentsState.ts
Normal file
50
src/state/MoreComponentsState.ts
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user