diff --git a/clients/typescript/README.md b/clients/typescript/README.md index 9aec225..da6b8b2 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -42,7 +42,12 @@ const token = client.auth.token; ## React ```tsx -import { PicloudProvider, useTopic, useEndpoint } from '@picloud/client/react'; +import { + PicloudProvider, + useTopic, + useEndpointGet, + useEndpointPost +} from '@picloud/client/react'; // Wrap your tree once: @@ -52,11 +57,25 @@ function ChatRoom({ roomId }: { roomId: string }) { } function UserProfile({ id }: { id: string }) { - const { data, loading, error } = useEndpoint(`/api/users/${id}`).get(); + // Auto-fires when `id` changes; conforms to Rules of Hooks. + const { data, loading, error } = useEndpointGet(`/api/users/${id}`); if (loading) return ; if (error) return ; return
{data?.name}
; } + +function CreateUserForm() { + // Event-driven: NOT fired on mount; call `mutate(body)` on submit. + const { mutate, loading, error } = useEndpointPost( + '/api/users' + ); + return ( +
{ e.preventDefault(); mutate({ name: 'Alice' }); }}> + + {error ? : null} + + ); +} ``` ## Svelte diff --git a/clients/typescript/package-lock.json b/clients/typescript/package-lock.json index 92cec06..ea4ea61 100644 --- a/clients/typescript/package-lock.json +++ b/clients/typescript/package-lock.json @@ -184,6 +184,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -207,6 +208,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1048,6 +1050,7 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -1117,6 +1120,7 @@ "integrity": "sha512-3ek6mwJL5/VBewBcY4S66cqlCtK3qi4WIq37Z0m/NHw1hjhI7274Mx1qz/+ggSzyBCOEf7eHjBN6INjPAWYfYw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1685,6 +1689,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2000,6 +2005,7 @@ "integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.1.0", "data-urls": "^5.0.0", @@ -2282,6 +2288,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2331,6 +2338,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", @@ -2414,6 +2422,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2427,6 +2436,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2851,6 +2861,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2872,6 +2883,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/clients/typescript/src/react/index.ts b/clients/typescript/src/react/index.ts index 0ae8026..797b458 100644 --- a/clients/typescript/src/react/index.ts +++ b/clients/typescript/src/react/index.ts @@ -60,26 +60,20 @@ export interface QueryState { error: unknown; } -export interface EndpointHook { - get: () => QueryState; - post: (body?: Req) => QueryState; +export interface MutationState { + data: T | null; + loading: boolean; + error: unknown; } /** - * Typed endpoint hook. `useEndpoint(path).get()` fires a GET and - * returns `{ data, loading, error }`, re-running when `path` changes. - * `.post(body)` is the mutation variant (auto-fires once per mount). + * F-T-001: flat GET hook. Replaces `useEndpoint(path).get()` which + * violated the Rules of Hooks (calling `useState`/`useEffect` from + * inside a returned function). Auto-fires once per `path` change and + * re-runs on mount. */ -export function useEndpoint(path: string): EndpointHook { +export function useEndpointGet(path: string): QueryState { const client = usePicloud(); - return { - get: () => useResource(() => client.endpoint(path).get(), path, 'GET'), - post: (body?: Req) => - useResource(() => client.endpoint(path).post(body), path, 'POST') - }; -} - -function useResource(run: () => Promise, key: string, method: string): QueryState { const [state, setState] = useState>({ data: null, loading: true, @@ -88,14 +82,42 @@ function useResource(run: () => Promise, key: string, method: string): useEffect(() => { let active = true; setState({ data: null, loading: true, error: null }); - run() + client + .endpoint(path) + .get() .then((data) => active && setState({ data, loading: false, error: null })) .catch((error) => active && setState({ data: null, loading: false, error })); return () => { active = false; }; - // `run` is recreated each render; key it on path + method instead. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [key, method]); + }, [client, path]); return state; } + +/** + * F-T-001 + F-T-002: event-driven POST hook. Returns + * `{ mutate, data, loading, error }` — `mutate(body)` fires the POST. + * Does NOT auto-fire on mount (the previous `useEndpoint(path).post()` + * shape would create a user / send an email on every render-refresh). + */ +export function useEndpointPost( + path: string +): MutationState & { mutate: (body?: Req) => Promise } { + const client = usePicloud(); + const [state, setState] = useState>({ + data: null, + loading: false, + error: null + }); + const mutate = async (body?: Req) => { + setState({ data: null, loading: true, error: null }); + try { + const data = await client.endpoint(path).post(body); + setState({ data, loading: false, error: null }); + } catch (error) { + setState({ data: null, loading: false, error }); + } + }; + return { ...state, mutate }; +}