| name | write-hook |
| description | Write React hooks following the Epic architecture patterns. Use when creating custom hooks for state management, server action calls, optimistic updates, and validation. Triggers on "create a hook", "add a hook", or "write a hook for". |
Write Hook
Overview
This skill creates React hooks that follow the Epic three-layer architecture. Hooks belong to the Frontend layer and handle state management, validation, server action calls, and optimistic updates.
Architecture Context
Frontend (Browser): Components -> Hooks -> State (Jotai)
|
v
Backend (Server): Actions (atomic) OR Routes (streaming)
Hooks:
- Run in the browser (Frontend layer)
- Manage state with Jotai atoms
- Validate inputs with Zod
- Call ONE backend entry point (Action or Route, never both)
- Handle optimistic updates and rollback
- NEVER access database or import server-only code
Backend Entry Point Rule
Each hook calls exactly ONE backend entry point:
Action (default):
- Import and call directly
- Most behaviors
- Simpler mental model
Route:
- Call via
fetchorfetchEventSource - Streaming/SSE, webhooks, HTTP semantics needed
- Supports both request/response and streaming
Never call both. Never call multiple endpoints.
Hook Location and Naming
app/[role]/[page]/behaviors/[behavior-name]/
hooks/
use-[behavior-name].ts # Hook file
actions/
[action-name].action.ts # Server action it calls
- File names start with
use-and match the exported function - Behavior folders use kebab-case
Hook Specification Format
Follow the Epic Hook specification format:
## useHookName(params?: ParamType)
[Short description of what stateful logic this hook encapsulates]
### Parameters
- paramName: Type - description
### State
- stateName: Type
- anotherState: Type
### Returns
- value: Type - description
- action: () => void - description
### Dependencies
- useOtherHook - why it's needed
Implementation Pattern
import { useAtom } from 'jotai';
import { useState } from 'react';
import { z } from 'zod';
import { actionName } from './actions/action-name.action';
import { itemsAtom } from '@/app/[role]/[page]/state';
// Validation schema
const InputSchema = z.object({
name: z.string().min(1).max(100),
});
export function useBehaviorName() {
const [items, setItems] = useAtom(itemsAtom);
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAction = async (data: unknown) => {
// 1. Validate input
const result = InputSchema.safeParse(data);
if (!result.success) {
setError(result.error.errors[0].message);
return;
}
setIsPending(true);
setError(null);
// 2. Optimistic update
const optimisticItem = {
...result.data,
id: crypto.randomUUID(),
pending: true,
};
setItems(prev => [...prev, optimisticItem]);
try {
// 3. Call server action
const response = await actionName(result.data);
if (response.success && response.data) {
// 4. Replace optimistic with real data
setItems(prev =>
prev.map(item =>
item.id === optimisticItem.id ? response.data : item
)
);
} else {
// 5. Rollback on failure
setItems(prev => prev.filter(item => item.id !== optimisticItem.id));
setError(response.error || 'Operation failed');
}
} catch (err) {
// 6. Rollback on error
setItems(prev => prev.filter(item => item.id !== optimisticItem.id));
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setIsPending(false);
}
};
return {
items,
isPending,
error,
handleAction,
};
}
Route Consumption Patterns
Non-Streaming Route
import { useAtom } from 'jotai';
import { useState } from 'react';
export function useRouteBehavior() {
const [result, setResult] = useAtom(resultAtom);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (input: Input) => {
setIsLoading(true);
setError(null);
try {
const response = await fetch(`/page/behaviors/behavior-name`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const data = await response.json();
if (data.success) {
setResult(data.data);
} else {
setError(data.error);
}
} catch (err) {
setError('Request failed');
} finally {
setIsLoading(false);
}
};
return { result, isLoading, error, handleSubmit };
}
Streaming Route (SSE)
import { fetchEventSource } from '@microsoft/fetch-event-source';
import { useAtom } from 'jotai';
import { useState, useRef } from 'react';
export function useStreamingBehavior() {
const [result, setResult] = useAtom(resultAtom);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const handleGenerate = async (input: Input) => {
setIsLoading(true);
setError(null);
setResult('');
abortControllerRef.current = new AbortController();
await fetchEventSource(`/page/behaviors/behavior-name`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
signal: abortControllerRef.current.signal,
onmessage(event) {
switch (event.event) {
case 'token':
setResult(prev => prev + event.data);
break;
case 'complete':
setIsLoading(false);
break;
case 'error':
setError(event.data);
setIsLoading(false);
break;
}
},
onclose() {
setIsLoading(false);
},
onerror(err) {
setError('Connection failed');
setIsLoading(false);
},
});
};
const handleCancel = () => {
abortControllerRef.current?.abort();
setIsLoading(false);
};
return { result, isLoading, error, handleGenerate, handleCancel };
}
Key Differences
| Aspect | Action | Route | Streaming Route |
|---|---|---|---|
| Import | Action function | fetch |
fetchEventSource |
| Call | await action(input) |
await fetch(url) |
await fetchEventSource(url) |
| Response | Single result | Single result | Multiple events |
| Cancellation | Not supported | Not typical | Via AbortController |
Key Patterns
1. Validation First
- Always validate input with Zod schemas before operations
- Use
safeParseand handle validation errors gracefully - Return early with error messages for invalid input
2. Optimistic Updates
- Add temporary records with
pending: trueflag - Generate temporary IDs with
crypto.randomUUID() - Always rollback on failure
- Replace optimistic items with real data on success
3. Error Handling
- Set loading states appropriately
- Clear previous errors before new operations
- Provide descriptive error messages
- Use try/catch with proper error type checking
4. State Management
- Use Jotai atoms defined in page's
state.ts - Return consistent object shape:
{ data, isPending, error, handlers } - Keep state updates atomic and predictable
5. Server Action Calls
- Import actions from
./actions/[action-name].action - Handle standard response:
{ success: boolean, data?: T, error?: string } - Never call actions directly from components
Constraints
- NEVER import database clients or models
- NEVER call more than one backend entry point (action or route)
- NEVER put business logic in hooks - that belongs in actions/routes
- ALWAYS include both loading and error states
- ALWAYS validate input before calling backend
- ALWAYS implement optimistic updates for better UX (actions)
- ALWAYS support cancellation for streaming behaviors (routes)
Example Specification
## useCreateProject()
Entry point for the Create Project behavior. Validates input, performs optimistic updates, and calls the server action.
### State
- isLoading: boolean
- error: string | null
### Returns
- handleCreateProject: (name: string) => Promise<void> - triggers the behavior
- isLoading: boolean - submission in progress
- error: string | null - current error message
### Dependencies
- useSetAtom(projectsAtom) - for optimistic updates
### Example: Create project successfully
#### PreState
projectsAtom: []
isLoading: false
error: null
#### Steps
* Call: handleCreateProject("New Project")
* Returns: void
#### PostState
projectsAtom: [{ id: "1", name: "New Project", status: "draft", pending: false }]
isLoading: false
error: null
### Example: Reject empty name
#### PreState
projectsAtom: []
isLoading: false
error: null
#### Steps
* Call: handleCreateProject("")
* Throws: "Name is required"
#### PostState
projectsAtom: []
isLoading: false
error: "Name is required"
Test Generation
Generate test files at [behavior-path]/tests/use-[behavior-name].test.tsx.
Test Structure
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { Provider } from 'jotai';
import { useHydrateAtoms } from 'jotai/utils';
import { useBehaviorName } from '../use-[behavior-name]';
import { itemsAtom } from '@/app/[role]/[page]/state';
// Helper to set initial atom state
function TestProvider({ initialValues, children }) {
return (
<Provider>
<HydrateAtoms initialValues={initialValues}>{children}</HydrateAtoms>
</Provider>
);
}
describe('useBehaviorName', () => {
it('should [behavior] when [condition]', async () => {
// PreState -> Initial atom values
const wrapper = ({ children }) => (
<TestProvider initialValues={[[itemsAtom, []]]}>
{children}
</TestProvider>
);
const { result } = renderHook(() => useBehaviorName(), { wrapper });
// Steps -> Execute handler
await act(async () => {
await result.current.handleAction({ name: 'New Item' });
});
// PostState -> Verify state changes
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});
});
Translation Rules
| Spec | Test |
|---|---|
| PreState (atoms) | Initial atom values in TestProvider |
Call: |
await result.current.handler(...) |
Returns: |
Verify handler completes |
Throws: |
expect(result.current.error).toBe(...) |
| PostState (atoms) | expect(result.current.state).toBe(...) |
Principles
- Test state transitions, not database
- Mock server actions if needed (hooks don't touch DB directly)
- Start with ONE test (happy path)