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,
"singleQuote": true,
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 100
}

View file

@ -1,16 +1,16 @@
import js from '@eslint/js';
import prettierConfig from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import js from "@eslint/js"
import prettierConfig from "eslint-config-prettier"
import reactHooks from "eslint-plugin-react-hooks"
import reactRefresh from "eslint-plugin-react-refresh"
import globals from "globals"
import tseslint from "typescript-eslint"
export default tseslint.config(
{
ignores: ['dist', 'node_modules'],
ignores: ["dist", "node_modules"],
},
{
files: ['src/**/*.{ts,tsx}'],
files: ["src/**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
...tseslint.configs.strictTypeChecked,
@ -25,32 +25,32 @@ export default tseslint.config(
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
"@typescript-eslint/consistent-type-imports": [
"error",
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
],
'@typescript-eslint/no-unused-vars': [
'error',
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
],
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'default-case': 'off',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
"@typescript-eslint/switch-exhaustiveness-check": "error",
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/no-unnecessary-condition": "error",
"@typescript-eslint/prefer-nullish-coalescing": "error",
"@typescript-eslint/prefer-optional-chain": "error",
"default-case": "off",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
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: 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 ImageRef = string
type Image = {
id: ImageId,
dimension: Dimension,
source: ImageRef,
id: ImageId
dimension: Dimension
source: ImageRef
}
// === Dimension ===
type Dimension = {
width: number,
height: number,
width: number
height: number
}
const Dimension = {
medium: { width: 200, height: 200 } as Dimension,
big: { width: 300, height: 500 } as Dimension,
}
// WARNING: page starts at 1
type Pagination = {
page: number,
limit: number, // page size
// === Page ===
type Page = {
page: number
limit: number // page size
}
const Pagination = {
init(): Pagination { return { page: 1, limit: 10 } },
}
const 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 ===
const picsumApiImageSchema = z.object({
@ -38,7 +68,7 @@ const picsumApiImageSchema = z.object({
})
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}`)
if (!response.ok) {
throw new Error(`Failed to fetch images: ${response.status}`)
@ -50,29 +80,170 @@ async function getPicsumImages({ page, limit }: Pagination): Promise<PicsumApiIm
return data
}
async function getImages(pagination: Pagination): Promise<Image[]> {
const data = await getPicsumImages(pagination)
return data.map(({ id, download_url, width, height }) => ({ id: id as ImageId, dimension: { width, height }, source: download_url as ImageRef }))
// TODO: We don't really need this.
async function getImages(page: Page): Promise<Image[]> {
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[]> {
const data = await getPicsumImages(pagination)
async function getImageIds(page: Page): Promise<ImageId[]> {
const data = await getPicsumImages(page)
return data.map(({ id }) => id)
}
// Use this for `<img src=... />`
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
// (async () => {
// const imgs = await getImages({ page: 2, limit: 10 })
// console.log(imgs)
// })()
// === Generic Remote Data ===
type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
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 (
<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 { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import App from "./App"
import "./index.css"
const rootElement = document.getElementById('root');
const rootElement = document.getElementById("root")
if (rootElement === null) {
throw new Error('Root element #root was not found.');
throw new Error("Root element #root was not found.")
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
)

View file

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