Introduce result type and generic safe fetch functions. Do proper error handling/display.

This commit is contained in:
Yura Dupyn 2026-05-15 10:43:18 +02:00
parent 9138a187a9
commit 65fd45fc16

View file

@ -1,5 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { useReducer, useRef, useEffect, createContext, useContext } from "react" import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react"
import { import {
CssBaseline, CssBaseline,
Box, Box,
@ -12,12 +12,51 @@ import {
// TODO: Improve error-handling. Introduce some proper server response. // TODO: Improve error-handling. Introduce some proper server response.
type Result<A, E> = { tag: "ok"; value: A } | { tag: "error"; error: E }
const Result = {
ok<A>(value: A): Result<A, never> {
return { tag: "ok", value }
},
err<E>(error: E): Result<never, E> {
return { tag: "error", error }
},
map<A, B, E>(result: Result<A, E>, f: (x: A) => B): Result<B, E> {
switch (result.tag) {
case "ok":
return { tag: "ok", value: f(result.value) }
case "error":
return { tag: "error", error: result.error }
}
},
}
type RequestError =
| { tag: "networkError"; error: unknown }
| { tag: "httpError"; status: number; statusText: string }
| { tag: "jsonParseError"; error: unknown }
| { tag: "invalidResponse"; error: z.ZodError }
const RequestError = {
toString(error: RequestError): string {
switch (error.tag) {
case "networkError":
return "Network error. Check your connection and try again."
case "httpError":
return `Server returned ${String(error.status)} ${error.statusText}.`
case "jsonParseError":
return "The server returned an invalid response."
case "invalidResponse":
return "The image data had an unexpected format."
}
},
}
// === Image === // === Image ===
type ImageId = string type ImageId = string
type ImageRef = string type ImageRef = string
// === Dimension === // === Dimension ===
type Dimension = { type Dimension = {
width: number width: number
height: number height: number
@ -67,6 +106,69 @@ const Page = {
}, },
} }
// === Generic safe fetch functions ===
async function fetchJsonSafe(url: string): Promise<Result<unknown, RequestError>> {
let response: Response
try {
response = await fetch(url)
} catch (error: unknown) {
return Result.err({ tag: "networkError", error })
}
if (!response.ok) {
return Result.err({
tag: "httpError",
status: response.status,
statusText: response.statusText,
})
} else {
try {
const json: unknown = await response.json()
return Result.ok(json)
} catch (error: unknown) {
return Result.err({ tag: "jsonParseError", error })
}
}
}
// Assumes `f` is some sort of a zod parsing function that may throw `z.ZodError`
async function fetchJsonSafeWith<A>(
url: string,
f: (json: unknown) => A,
): Promise<Result<A, RequestError>> {
const result = await fetchJsonSafe(url)
switch (result.tag) {
case "error":
return Result.err(result.error)
case "ok":
// TODO
try {
return Result.ok(f(result.value))
} catch (error: unknown) {
if (error instanceof z.ZodError) {
return Result.err({ tag: "invalidResponse", error })
} else {
throw error
}
}
}
}
// === Generic Remote Data ===
type Remote<A, E> = { tag: "loading" } | Result<A, E>
const Remote = {
loading(): Remote<never, never> {
return { tag: "loading" }
},
error<E>(error: E): Remote<never, E> {
return { tag: "error", error }
},
loaded<A>(value: A): Remote<A, never> {
return { tag: "ok", value }
},
}
// === api === // === api ===
const picsumApiImageSchema = z.object({ const picsumApiImageSchema = z.object({
id: z.string(), id: z.string(),
@ -78,23 +180,19 @@ const picsumApiImageSchema = z.object({
}) })
type PicsumApiImage = z.infer<typeof picsumApiImageSchema> type PicsumApiImage = z.infer<typeof picsumApiImageSchema>
async function getPicsumImages({ page, limit }: Page): Promise<PicsumApiImage[]> { async function getPicsumImages({
const response = await fetch( page,
limit,
}: Page): Promise<Result<PicsumApiImage[], RequestError>> {
return fetchJsonSafeWith(
`https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`, `https://picsum.photos/v2/list?limit=${String(limit)}&page=${String(page)}`,
(json) => z.array(picsumApiImageSchema).parse(json),
) )
if (!response.ok) {
throw new Error(`Failed to fetch images: ${String(response.status)}`)
}
const json: unknown = await response.json()
const data = z.array(picsumApiImageSchema).parse(json)
return data
} }
async function getImageIds(page: Page): Promise<ImageId[]> { async function getImageIds(page: Page): Promise<Result<ImageId[], RequestError>> {
const data = await getPicsumImages(page) const result = await getPicsumImages(page)
return data.map(({ id }) => id) return Result.map(result, (data) => data.map(({ id }) => id))
} }
// Use this for `<img src=... />` // Use this for `<img src=... />`
@ -102,29 +200,15 @@ function getImageSource(id: ImageId, dimension: Dimension): ImageRef {
return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}` return `https://picsum.photos/id/${id}/${String(dimension.width)}/${String(dimension.height)}`
} }
// === Generic Remote Data ===
type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
const Remote = {
loading(): Remote<never, never> {
return { tag: "loading" }
},
error<E>(error: E): Remote<never, E> {
return { tag: "error", error }
},
loaded<A>(value: A): Remote<A, never> {
return { tag: "loaded", value }
},
}
// === App === // === App ===
// TODO: introduce proper error type // TODO: introduce proper error type
type AppError = string type AppError = RequestError
type State = { type State = {
imageIds: Remote<ImageId[], AppError> imageIds: Remote<ImageId[], AppError>
page: Page page: Page
selectedImage: ImageId | undefined selectedImage: ImageId | undefined
refreshSignal: boolean // This is here only because of the refresh button. It is an artificial way to trigger a reload without the current page changing. The value is meaningless. It just has to change.
} }
const State = { const State = {
@ -133,12 +217,14 @@ const State = {
imageIds: Remote.loading(), imageIds: Remote.loading(),
page: Page.init(), page: Page.init(),
selectedImage: undefined, selectedImage: undefined,
refreshSignal: false,
} }
}, },
} }
type Msg = type Msg =
| { tag: "imagesReceived"; imageIds: ImageId[]; forPage: Page } | { tag: "imagesReceived"; result: Result<ImageId[], AppError>; forPage: Page }
| { tag: "retryButtonClicked" }
| { tag: "previousButtonClicked" } | { tag: "previousButtonClicked" }
| { tag: "nextButtonClicked" } | { tag: "nextButtonClicked" }
| { tag: "imageClicked"; imageId: ImageId } | { tag: "imageClicked"; imageId: ImageId }
@ -151,36 +237,42 @@ function useApp(): [State, Dispatch] {
// Could also cache `Promise<ImageId[]>` in case two requests are made really fast one after another (not really the case in this app so whatever) // Could also cache `Promise<ImageId[]>` in case two requests are made really fast one after another (not really the case in this app so whatever)
const cacheRef = useRef<Map<PageKey, ImageId[]>>(new Map()) const cacheRef = useRef<Map<PageKey, ImageId[]>>(new Map())
async function getImageIdsCached(page: Page): Promise<ImageId[]> { async function getImageIdsCached(page: Page): Promise<Result<ImageId[], AppError>> {
const pageKey = Page.key(page) const pageKey = Page.key(page)
const maybeImages = cacheRef.current.get(pageKey) const maybeImages = cacheRef.current.get(pageKey)
if (maybeImages === undefined) { if (maybeImages === undefined) {
console.log("CACHE-MISS") // console.log("CACHE-MISS")
const images = await getImageIds(page) const result = await getImageIds(page)
cacheRef.current.set(pageKey, images) if (result.tag === "ok") {
return images cacheRef.current.set(pageKey, result.value)
}
return result
} else { } else {
console.log("CACHE-HIT") // console.log("CACHE-HIT")
return maybeImages return Result.ok(maybeImages)
} }
} }
// === initialization & reloading === // === initialization & reloading ===
useEffect(() => { useEffect(() => {
// TODO: error-handling // TODO: error-handling
getImageIdsCached(state.page).then((imageIds) => { void getImageIdsCached(state.page).then((result) => {
dispatch({ tag: "imagesReceived", forPage: state.page, imageIds }) dispatch({ tag: "imagesReceived", forPage: state.page, result })
}) })
}, [state.page]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm }, [state.page, state.refreshSignal]) // Would have been amazing if we could put `Page.key(state.page)` inside of this. Then the trouble with identity would be gone. But we can't, because React compiler and linter would complain /facepalm
function update(state: State, msg: Msg): State { function update(state: State, msg: Msg): State {
switch (msg.tag) { switch (msg.tag) {
case "imagesReceived": case "imagesReceived":
if (Page.eq(state.page, msg.forPage)) { if (Page.eq(state.page, msg.forPage)) {
return { ...state, imageIds: Remote.loaded(msg.imageIds) } return { ...state, imageIds: msg.result }
} else { } else {
return state return state
} }
case "retryButtonClicked":
console.log("TODO")
// TODO: This actually needs to trigger the reload somehow /facepalm. But we can refresh only on page change.
return { ...state, imageIds: Remote.loading(), refreshSignal: !state.refreshSignal }
case "previousButtonClicked": { case "previousButtonClicked": {
const newPage = Page.previous(state.page) const newPage = Page.previous(state.page)
if (Page.eq(newPage, state.page)) { if (Page.eq(newPage, state.page)) {
@ -193,11 +285,9 @@ function useApp(): [State, Dispatch] {
return { ...state, page: Page.next(state.page), imageIds: Remote.loading() } return { ...state, page: Page.next(state.page), imageIds: Remote.loading() }
} }
case "imageClicked": { case "imageClicked": {
console.log(msg.imageId)
return { ...state, selectedImage: msg.imageId } return { ...state, selectedImage: msg.imageId }
} }
case "modalCloseButtonClicked": { case "modalCloseButtonClicked": {
console.log("modal closed")
return { ...state, selectedImage: undefined } return { ...state, selectedImage: undefined }
} }
default: default:
@ -223,16 +313,51 @@ function useDispatch() {
// === Views === // === Views ===
function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) { function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
const dispatch = useDispatch()
switch (images.tag) { switch (images.tag) {
case "loading": case "loading":
return <ImagesSkeleton /> return <ImagesSkeleton />
case "error": case "error":
return <div>Error: TODO</div> return (
case "loaded": <Box sx={{ position: "relative" }}>
<ImagesSkeleton visible={false} />
<Box sx={{ position: "absolute", inset: 0, display: "grid", placeItems: "center" }}>
<ErrorView
error={images.error}
onRetry={() => {
dispatch({ tag: "retryButtonClicked" })
}}
/>
</Box>
</Box>
)
case "ok":
return <Images images={images.value} /> return <Images images={images.value} />
} }
} }
function ErrorView({ error, onRetry }: { error: AppError; onRetry: () => void }) {
return (
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 1,
textAlign: "center",
}}
>
<Typography variant="h6">Could not load images</Typography>
<Typography variant="body2" sx={{ color: "oklch(50% 0.02 var(--base-hue))" }}>
{RequestError.toString(error)}
</Typography>
<Button variant="outlined" onClick={onRetry}>
Retry
</Button>
</Box>
)
}
function imageGridStyle(dimension: Dimension) { function imageGridStyle(dimension: Dimension) {
return { return {
display: "grid", display: "grid",
@ -246,18 +371,29 @@ function imageGridStyle(dimension: Dimension) {
} }
// 2x5 on mobile, 5x2 on desktop (assuming 10 images per page) // 2x5 on mobile, 5x2 on desktop (assuming 10 images per page)
function ImagesSkeleton() { function ImagesSkeleton({ visible = true }: { visible?: boolean }) {
const images = Array.from({ length: 10 }) const images = Array.from({ length: 10 })
return ( return (
<Box sx={imageGridStyle(Dimension.medium)}> <Box sx={imageGridStyle(Dimension.medium)}>
{images.map((_, index) => ( {images.map((_, index) =>
<Skeleton visible ? (
key={index} <Skeleton
variant="rectangular" key={index}
width={Dimension.medium.width} animation="pulse"
height={Dimension.medium.height} variant="rectangular"
></Skeleton> width={Dimension.medium.width}
))} height={Dimension.medium.height}
/>
) : (
<Box
key={index}
sx={{
width: Dimension.medium.width,
height: Dimension.medium.height,
}}
/>
),
)}
</Box> </Box>
) )
} }
@ -267,26 +403,68 @@ function Images({ images }: { images: ImageId[] }) {
return ( return (
<Box sx={imageGridStyle(Dimension.medium)}> <Box sx={imageGridStyle(Dimension.medium)}>
{images.map((imageId) => ( {images.map((imageId) => (
<Box <Image
component="img" imageId={imageId}
src={getImageSource(imageId, Dimension.medium)} dimension={Dimension.medium}
onClick={() => { onClick={() => {
dispatch({ tag: "imageClicked", imageId }) dispatch({ tag: "imageClicked", imageId })
}} }}
sx={{
width: Dimension.medium.width,
height: Dimension.medium.height,
objectFit: "cover",
cursor: "pointer",
}}
key={imageId} key={imageId}
alt=""
/> />
))} ))}
</Box> </Box>
) )
} }
function Image({
imageId,
dimension,
onClick,
}: {
imageId: ImageId
dimension: Dimension
onClick?: () => void
}) {
const [loaded, setLoaded] = useState(false)
return (
<Box
sx={{
position: "relative",
width: dimension.width,
height: dimension.height,
}}
>
{!loaded && (
<Skeleton
animation="pulse"
variant="rectangular"
width={dimension.width}
height={dimension.height}
/>
)}
<Box
component="img"
src={getImageSource(imageId, dimension)}
onLoad={() => {
setLoaded(true)
}}
onClick={onClick}
sx={{
position: "absolute",
inset: 0,
width: dimension.width,
height: dimension.height,
objectFit: "cover",
cursor: onClick === undefined ? "default" : "pointer",
opacity: loaded ? 1 : 0,
}}
alt=""
/>
</Box>
)
}
function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
const dispatch = useDispatch() const dispatch = useDispatch()
return ( return (
@ -298,19 +476,7 @@ function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) {
maxWidth={false} maxWidth={false}
> >
<DialogContent sx={{ p: 0, overflow: "hidden" }}> <DialogContent sx={{ p: 0, overflow: "hidden" }}>
{selectedImage !== undefined && ( {selectedImage !== undefined && <Image imageId={selectedImage} dimension={Dimension.big} />}
<Box
component="img"
src={getImageSource(selectedImage, Dimension.big)}
sx={{
display: "block",
maxWidth: "90vw",
maxHeight: "80vh",
objectFit: "contain",
}}
alt=""
/>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )