Add Remote type constructor. Setup basic State/Msg/effects/Reducer. Format

This commit is contained in:
Yura Dupyn 2026-05-14 23:03:48 +02:00
parent ae6451e052
commit 473897fdfc
5 changed files with 238 additions and 67 deletions

View file

@ -1,6 +1,6 @@
{ {
"semi": true, "semi": false,
"singleQuote": true, "singleQuote": false,
"trailingComma": "all", "trailingComma": "all",
"printWidth": 100 "printWidth": 100
} }

View file

@ -1,16 +1,16 @@
import js from '@eslint/js'; import js from "@eslint/js"
import prettierConfig from 'eslint-config-prettier'; import prettierConfig from "eslint-config-prettier"
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from 'eslint-plugin-react-refresh'; import reactRefresh from "eslint-plugin-react-refresh"
import globals from 'globals'; import globals from "globals"
import tseslint from 'typescript-eslint'; import tseslint from "typescript-eslint"
export default tseslint.config( export default tseslint.config(
{ {
ignores: ['dist', 'node_modules'], ignores: ["dist", "node_modules"],
}, },
{ {
files: ['src/**/*.{ts,tsx}'], files: ["src/**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
...tseslint.configs.strictTypeChecked, ...tseslint.configs.strictTypeChecked,
@ -25,32 +25,32 @@ export default tseslint.config(
}, },
}, },
plugins: { plugins: {
'react-hooks': reactHooks, "react-hooks": reactHooks,
'react-refresh': reactRefresh, "react-refresh": reactRefresh,
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
'@typescript-eslint/consistent-type-imports': [ "@typescript-eslint/consistent-type-imports": [
'error', "error",
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' }, { prefer: "type-imports", fixStyle: "inline-type-imports" },
], ],
'@typescript-eslint/no-unused-vars': [ "@typescript-eslint/no-unused-vars": [
'error', "error",
{ {
argsIgnorePattern: '^_', argsIgnorePattern: "^_",
varsIgnorePattern: '^_', varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: '^_', caughtErrorsIgnorePattern: "^_",
}, },
], ],
'@typescript-eslint/switch-exhaustiveness-check': 'error', "@typescript-eslint/switch-exhaustiveness-check": "error",
'@typescript-eslint/no-floating-promises': 'error', "@typescript-eslint/no-floating-promises": "error",
'@typescript-eslint/no-misused-promises': 'error', "@typescript-eslint/no-misused-promises": "error",
'@typescript-eslint/no-unnecessary-condition': 'error', "@typescript-eslint/no-unnecessary-condition": "error",
'@typescript-eslint/prefer-nullish-coalescing': 'error', "@typescript-eslint/prefer-nullish-coalescing": "error",
'@typescript-eslint/prefer-optional-chain': 'error', "@typescript-eslint/prefer-optional-chain": "error",
'default-case': 'off', "default-case": "off",
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
}, },
}, },
prettierConfig, prettierConfig,
); )

View file

@ -1,31 +1,61 @@
import { z } from 'zod' import { z } from "zod"
import { useReducer, useEffect, createContext, useContext } from "react"
// TODO: Use Material UI later. // TODO: Use Material UI later.
// TODO: Improve error-handling. Introduce some proper server response. // TODO: Improve error-handling. Introduce some proper server response.
// For better type-error messages when you have inexhaustive switch cases.
function assertNever(value: never): never {
throw new Error(`Unexpected value: ${value}`)
}
// === Image ===
type ImageId = string type ImageId = string
type ImageRef = string type ImageRef = string
type Image = { type Image = {
id: ImageId, id: ImageId
dimension: Dimension, dimension: Dimension
source: ImageRef, source: ImageRef
} }
// === Dimension ===
type Dimension = { type Dimension = {
width: number, width: number
height: number, height: number
}
const Dimension = {
medium: { width: 200, height: 200 } as Dimension,
big: { width: 300, height: 500 } as Dimension,
} }
// WARNING: page starts at 1 // === Page ===
type Pagination = { type Page = {
page: number, page: number
limit: number, // page size limit: number // page size
} }
const Pagination = { const LIMIT = 10
init(): Pagination { return { page: 1, limit: 10 } }, const FIRST_PAGE = 1
}
const Page = {
init(): Page {
return { page: FIRST_PAGE, limit: LIMIT }
},
next(page: Page): Page {
return { ...page, page: page.page + 1 }
},
previous(page: Page): Page {
// Could do
// if (page.page == FIRST_PAGE) { return page }
// this preserves identity of object, so is nicer for `useEffect`, but it's waaaay to subtle. So I'm not relying on that.
return { ...page, page: Math.max(FIRST_PAGE, page.page - 1) }
},
eq(page0: Page, page1: Page): boolean {
return page0.page === page1.page && page0.limit === page1.limit
},
}
// === api === // === api ===
const picsumApiImageSchema = z.object({ const picsumApiImageSchema = z.object({
@ -38,7 +68,7 @@ const picsumApiImageSchema = z.object({
}) })
type PicsumApiImage = z.infer<typeof picsumApiImageSchema> type PicsumApiImage = z.infer<typeof picsumApiImageSchema>
async function getPicsumImages({ page, limit }: Pagination): Promise<PicsumApiImage[]> { async function getPicsumImages({ page, limit }: Page): Promise<PicsumApiImage[]> {
const response = await fetch(`https://picsum.photos/v2/list?limit=${limit}&page=${page}`) const response = await fetch(`https://picsum.photos/v2/list?limit=${limit}&page=${page}`)
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch images: ${response.status}`) throw new Error(`Failed to fetch images: ${response.status}`)
@ -50,29 +80,170 @@ async function getPicsumImages({ page, limit }: Pagination): Promise<PicsumApiIm
return data return data
} }
async function getImages(pagination: Pagination): Promise<Image[]> { // TODO: We don't really need this.
const data = await getPicsumImages(pagination) async function getImages(page: Page): Promise<Image[]> {
return data.map(({ id, download_url, width, height }) => ({ id: id as ImageId, dimension: { width, height }, source: download_url as ImageRef })) const data = await getPicsumImages(page)
return data.map(({ id, download_url, width, height }) => ({
id: id as ImageId,
dimension: { width, height },
source: download_url as ImageRef,
}))
} }
async function getImageIds(pagination: Pagination): Promise<ImageId[]> { async function getImageIds(page: Page): Promise<ImageId[]> {
const data = await getPicsumImages(pagination) const data = await getPicsumImages(page)
return data.map(({ id }) => id) return data.map(({ id }) => id)
} }
// Use this for `<img src=... />` // Use this for `<img src=... />`
function getImageSource(id: ImageId, dimension: Dimension): ImageRef { function getImageSource(id: ImageId, dimension: Dimension): ImageRef {
return `https://picsum.photos/id/${id}/${dimension.width}/${dimension.height}`; return `https://picsum.photos/id/${id}/${dimension.width}/${dimension.height}`
} }
// test // === Generic Remote Data ===
// (async () => { type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
// const imgs = await getImages({ page: 2, limit: 10 })
// console.log(imgs)
// })()
export default function App() { 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 ===
// TODO: introduce proper error type
type AppError = string
type State = {
imageIds: Remote<ImageId[], AppError>
page: Page
selectedImage: ImageId | undefined
}
const State = {
init(): State {
return {
imageIds: Remote.loading(),
page: Page.init(),
selectedImage: undefined,
}
},
}
type Msg =
| { tag: "imagesReceived"; imageIds: ImageId[]; forPage: Page }
| { tag: "previousButtonClicked" }
| { tag: "nextButtonClicked" }
| { tag: "imageClicked"; imageId: ImageId }
| { tag: "modalCloseButtonClicked" }
function useApp(): [State, Dispatch] {
const [state, dispatch] = useReducer(update, State.init())
// === initialization & reloading ===
useEffect(() => {
// TODO: error-handling
getImageIds(state.page).then((imageIds) => {
dispatch({ tag: "imagesReceived", forPage: state.page, imageIds })
})
}, [state.page])
function update(state: State, msg: Msg): State {
switch (msg.tag) {
case "imagesReceived":
if (Page.eq(state.page, msg.forPage)) {
return { ...state, imageIds: Remote.loaded(msg.imageIds) }
} else {
return state
}
case "previousButtonClicked": {
console.log("prev")
const newPage = Page.previous(state.page)
if (Page.eq(newPage, state.page)) {
return state // preserves identity
} else {
return { ...state, page: newPage, imageIds: Remote.loading() }
}
}
case "nextButtonClicked": {
console.log("next")
return { ...state, page: Page.next(state.page), imageIds: Remote.loading() }
}
case "imageClicked": {
console.log(msg.imageId)
return { ...state, selectedImage: msg.imageId }
}
case "modalCloseButtonClicked": {
console.log("modal closed")
return { ...state, selectedImage: undefined }
}
default:
return assertNever(msg)
}
}
return [state, dispatch]
}
type Dispatch = (msg: Msg) => void
const DispatchContext = createContext<Dispatch | null>(null)
function useDispatch() {
const dispatch = useContext(DispatchContext)
if (dispatch === null) {
throw new Error("useDispatch must be used inside DispatchContext.Provider")
}
return dispatch
}
// === Views ===
function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
switch (images.tag) {
case "loading":
return <div>Loading...</div>
case "error":
return <div>Error: TODO</div>
case "loaded":
return <Images images={images.value} />
}
}
function Images({ images }: { images: ImageId[] }) {
const dispatch = useDispatch()
// TODO: Is there some basic `Col/Row` component?
return ( return (
<h1>hello world</h1> <div style={{ display: "flex", flexDirection: "column" }}>
{images.map((imageId) => (
<img
style={{ width: Dimension.medium.width, height: Dimension.medium.height }}
key={imageId}
src={getImageSource(imageId, Dimension.medium)}
onClick={() => dispatch({ tag: "imageClicked", imageId })}
alt=""
/>
))}
</div>
)
}
export default function App() {
const [state, dispatch] = useApp()
return (
<DispatchContext.Provider value={dispatch}>
<main>
<h1>Picturarium</h1>
<RemoteImages images={state.imageIds} />
<button onClick={() => dispatch({ tag: "previousButtonClicked" })}>prev</button>
<button onClick={() => dispatch({ tag: "nextButtonClicked" })}>next</button>
</main>
</DispatchContext.Provider>
) )
} }

View file

@ -1,16 +1,16 @@
import { StrictMode } from 'react'; import { StrictMode } from "react"
import { createRoot } from 'react-dom/client'; import { createRoot } from "react-dom/client"
import App from './App'; import App from "./App"
import './index.css'; import "./index.css"
const rootElement = document.getElementById('root'); const rootElement = document.getElementById("root")
if (rootElement === null) { if (rootElement === null) {
throw new Error('Root element #root was not found.'); throw new Error("Root element #root was not found.")
} }
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<App /> <App />
</StrictMode>, </StrictMode>,
); )

View file

@ -1,6 +1,6 @@
import react from '@vitejs/plugin-react'; import react from "@vitejs/plugin-react"
import { defineConfig } from 'vite'; import { defineConfig } from "vite"
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
}); })