Created
February 11, 2026 06:03
-
-
Save schalkventer/9d0f773f0a90a4cd99e67a0eee3b0add to your computer and use it in GitHub Desktop.
π½ OCRUD
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { useEffect } from "react"; | |
| import { createCrudService } from "../crud"; | |
| import { faker as f } from "@faker-js/faker"; | |
| /** | |
| * Mocking function to emulate network latency for demonstration purposes. | |
| */ | |
| const delay = (ms: number) => new Promise((res) => setTimeout(res, ms)); | |
| /** | |
| * Mocked HTTP server responses for demonstration purposes. | |
| */ | |
| const http = { | |
| get: async (): Promise<Task[]> => { | |
| await delay(3000); | |
| return Array.from({ length: 100 }, () => ({ | |
| id: f.string.uuid() as Task["id"], | |
| label: f.lorem.sentence(), | |
| completed: f.datatype.boolean(), | |
| })); | |
| }, | |
| post: async (values: Pick<Task, "label">) => { | |
| console.log("post", values); | |
| await delay(3000); | |
| return { | |
| id: f.string.uuid() as Task["id"], | |
| }; | |
| }, | |
| put: async (values: Pick<Task, "id" | "label" | "completed">) => { | |
| console.log("put", values); | |
| await delay(3000); | |
| return { | |
| id: values.id, | |
| }; | |
| }, | |
| delete: async (id: Task["id"]) => { | |
| console.log("delete", id); | |
| await delay(3000); | |
| return { | |
| id, | |
| }; | |
| }, | |
| }; | |
| /** | |
| * Basic item to be used in example, if for example the this is a to-do app. | |
| */ | |
| type Task = { | |
| id: string & { __brand: "EXAMPLE_ID" }; | |
| label: string; | |
| completed: boolean; | |
| }; | |
| const service = createCrudService<Task>({ | |
| entity: "task", | |
| pull: http.get, | |
| push: async ({ type, value }) => { | |
| if (type === "insert") return http.post(value); | |
| if (type === "remove") return http.delete(value); | |
| return http.put(value); | |
| }, | |
| }); | |
| type Overlay = | |
| | { | |
| variant: "adding"; | |
| details?: never; | |
| } | |
| | { | |
| variant: "editing" | "removing"; | |
| details: { | |
| target: Task["id"]; | |
| }; | |
| }; | |
| export const ExampleComponent = () => { | |
| const [overlay, setOverlay] = useState<"null" | "adding" | "editing" | "removing">(null); | |
| useEffect(() => { | |
| service.sync(); | |
| }, []); | |
| const list = service.useList(); | |
| const syncing = service.useSyncing(); | |
| if (!list) return <p>Loading...</p>; | |
| return ( | |
| <> | |
| <div>{syncing ? "π" : "β "}</div> | |
| <ul> | |
| {Object.values(list).map((x) => ( | |
| <li key={x.id}> | |
| <input | |
| type="checkbox" | |
| checked={x.completed} | |
| onChange={() => { | |
| service.update(x.id, (y) => { | |
| y.completed = !y.completed; | |
| }); | |
| }} | |
| /> | |
| <span>{x.label}</span> - {x.completed ? "Completed" : "Not Completed"} | |
| </li> | |
| ))} | |
| </ul> | |
| </> | |
| ); | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { produce } from "immer"; | |
| import { createStore, useStore } from "zustand"; | |
| import { recordFrom } from "@qr-fox/types"; | |
| export type CrudStore<T extends { id: string }> = { | |
| updated: number; | |
| pending: boolean; | |
| list: Record<T["id"], T> | null; | |
| pulling: number; | |
| pushing: number; | |
| }; | |
| export const createCrudService = <T extends { id: K }, K extends string = string>(config: { | |
| entity: string; | |
| pull: () => Promise<T[]>; | |
| push: ( | |
| props: | |
| | { type: "remove"; value: T["id"] } | |
| | { type: "insert"; value: Omit<T, "id"> } | |
| | { type: "update"; value: T } | |
| ) => Promise<{ id: T["id"] }>; | |
| }) => { | |
| const { pull, push, entity } = config; | |
| const store = createStore<CrudStore<T>>(() => ({ | |
| pending: false, | |
| pulling: 0, | |
| pushing: 0, | |
| list: null, | |
| updated: 0, | |
| })); | |
| const inner = (fn: (current: Record<T["id"], T>) => void) => { | |
| if (!store.getState().list) { | |
| throw new Error(`Cannot mutate ${entity} list before it is loaded.`); | |
| } | |
| const prev = store.getState().list; | |
| const next = produce(prev, fn); | |
| store.setState({ | |
| list: next, | |
| updated: Date.now(), | |
| }); | |
| }; | |
| const sync = async () => { | |
| if (store.getState().pulling > 0) { | |
| return store.setState({ pending: true }); | |
| } | |
| store.setState((x) => ({ pulling: x.pulling + 1 })); | |
| const response = await pull(); | |
| store.setState({ | |
| list: recordFrom(response), | |
| updated: Date.now(), | |
| }); | |
| store.setState((x) => ({ pulling: x.pulling - 1 })); | |
| }; | |
| return { | |
| sync, | |
| remove: async (id: T["id"]) => { | |
| store.setState((x) => ({ pushing: x.pushing + 1 })); | |
| inner((list) => delete list[id]); | |
| await push({ type: "remove", value: id }); | |
| const { pending, pulling } = store.getState(); | |
| if (pending && pulling === 1) { | |
| store.setState({ pending: false, pulling: 0 }); | |
| return sync(); | |
| } | |
| store.setState((x) => ({ pushing: x.pushing + 1 })); | |
| }, | |
| insert: async (item: Omit<T, "id">) => { | |
| store.setState((x) => ({ pushing: x.pushing + 1 })); | |
| const response = await push({ type: "insert", value: item }); | |
| if (!response?.id) { | |
| throw new Error(`Failed to insert ${entity}. Response did not contain id.`); | |
| } | |
| inner((x) => { | |
| x[response.id] = { ...item, id: response.id } as T; | |
| }); | |
| const { pending, pulling } = store.getState(); | |
| if (pending && pulling === 1) { | |
| store.setState({ pending: false, pulling: 0 }); | |
| return sync(); | |
| } | |
| store.setState((x) => ({ pushing: x.pushing + 1 })); | |
| }, | |
| update: async (id: T["id"], mutate: (current: T) => void) => { | |
| const { list } = store.getState(); | |
| if (!list) { | |
| throw new Error(`Cannot update ${entity} list before it is loaded.`); | |
| } | |
| const prev = list[id]; | |
| const next = produce(prev, mutate); | |
| if (prev.id !== next.id) { | |
| throw new Error(`Cannot change id of ${entity} from "${prev.id}" to "${next.id}".`); | |
| } | |
| store.setState((x) => ({ pushing: x.pushing + 1 })); | |
| inner((x) => { | |
| x[id] = next; | |
| }); | |
| await push({ type: "update", value: next }); | |
| const { pending, pulling } = store.getState(); | |
| if (pending && pulling === 1) { | |
| store.setState({ pending: false, pulling: 0 }); | |
| return sync(); | |
| } | |
| store.setState((x) => ({ pushing: x.pushing + 1 })); | |
| }, | |
| useList: () => useStore(store, (x) => x.list), | |
| useUpdated: () => useStore(store, (x) => x.updated), | |
| usePushing: () => useStore(store, (x) => x.pushing), | |
| usePulling: () => useStore(store, (x) => x.pulling), | |
| useSyncing: () => useStore(store, (x) => x.pushing > 0 || x.pulling > 0 || x.pending), | |
| }; | |
| }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment