Skip to main content

Hook Guidelines

Custom hooks and state management patterns for Viben frontend.

Overview​

This document covers custom hooks naming conventions, state management patterns, data fetching, and performance optimization guidelines.

Naming Conventions​

Custom Hook Names​

All custom hooks must start with use prefix:

// Good
useAgentSession()
useWorkspaceData()
useTaskStatus()

// Bad
getAgentSession()
fetchWorkspaceData()
taskStatusHook()

Naming Patterns​

PatternUse CaseExample
use[Entity]Single entity stateuseAgent, useTask
use[Entity]ListList of entitiesuseAgentList, useTaskList
use[Entity][Action]Entity actionsuseAgentCreate, useTaskUpdate
use[Feature]Feature-specificuseBackgroundTasks, useChatInput

State Management​

Local State (useState)​

Use for component-level state that doesn't need to be shared:

// Simple toggle
const [isOpen, setIsOpen] = useState(false);

// Object state
const [formData, setFormData] = useState<FormData>({
name: "",
email: "",
});

// Derived state - prefer useMemo
const filteredItems = useMemo(
() => items.filter((item) => item.active),
[items]
);

Complex State (useReducer)​

Use for complex state logic with multiple related values:

type State = {
status: "idle" | "loading" | "success" | "error";
data: Data | null;
error: Error | null;
};

type Action =
| { type: "FETCH_START" }
| { type: "FETCH_SUCCESS"; payload: Data }
| { type: "FETCH_ERROR"; error: Error };

function reducer(state: State, action: Action): State {
switch (action.type) {
case "FETCH_START":
return { ...state, status: "loading", error: null };
case "FETCH_SUCCESS":
return { status: "success", data: action.payload, error: null };
case "FETCH_ERROR":
return { status: "error", data: null, error: action.error };
default:
return state;
}
}

function useDataFetcher() {
const [state, dispatch] = useReducer(reducer, {
status: "idle",
data: null,
error: null,
});
// ...
}

Global State (Zustand)​

Use Zustand for global state that needs to be shared across components:

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface WorkspaceStore {
currentWorkspace: Workspace | null;
setCurrentWorkspace: (workspace: Workspace) => void;
clearWorkspace: () => void;
}

export const useWorkspaceStore = create<WorkspaceStore>()(
persist(
(set) => ({
currentWorkspace: null,
setCurrentWorkspace: (workspace) =>
set({ currentWorkspace: workspace }),
clearWorkspace: () => set({ currentWorkspace: null }),
}),
{
name: "workspace-storage",
}
)
);

// Usage in component
function WorkspaceSelector() {
const { currentWorkspace, setCurrentWorkspace } = useWorkspaceStore();
// ...
}

Data Fetching​

TanStack Query Patterns (Primary)​

Use TanStack Query for data fetching with caching, background refetching, and request state management:

import { useQuery } from "@tanstack/react-query";

// Basic fetching
function useAgents(workspacePath: string) {
const { data, error, isLoading, refetch } = useQuery({
queryKey: ["agents", workspacePath],
queryFn: () =>
fetcher(`/api/agent?workspace_path=${workspacePath}`),
enabled: !!workspacePath,
});

return {
agents: data?.agents ?? [],
isLoading,
isError: !!error,
refresh: refetch,
};
}

// Conditional fetching
function useTask(taskId: string | undefined) {
const { data, error } = useQuery({
queryKey: ["task", taskId],
queryFn: () => fetcher(`/api/task/${taskId}`),
enabled: !!taskId, // disabled queries skip fetching
});
return { task: data, isError: !!error };
}

// Mutation with cache invalidation
function useCreateTask() {
const queryClient = useQueryClient();

return useMutation({
mutationFn: createTask,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
},
});
}

// Query with type safety and stale time
function useAgent(agentId: string) {
return useQuery({
queryKey: ["agent", agentId],
queryFn: () => fetchAgent(agentId),
enabled: !!agentId,
staleTime: 5 * 60 * 1000, // 5 minutes
});
}

SWR Patterns (Alternative)​

SWR can also be used for simpler data fetching scenarios with caching and revalidation:

import useSWR from "swr";

// Basic fetching
function useAgents(workspacePath: string) {
const { data, error, isLoading, mutate } = useSWR(
workspacePath ? `/api/agent?workspace_path=${workspacePath}` : null,
fetcher
);

return {
agents: data?.agents ?? [],
isLoading,
isError: !!error,
refresh: mutate,
};
}

// Mutation with optimistic update
function useUpdateTask() {
const { mutate } = useSWRConfig();

return async (taskId: string, updates: Partial<Task>) => {
await mutate(
`/api/task/${taskId}`,
async (current: Task) => {
const response = await fetch(`/api/task/${taskId}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
return response.json();
},
{
optimisticData: (current) => ({ ...current, ...updates }),
rollbackOnError: true,
}
);
};
}

Memoization Guidelines​

useMemo​

Use for expensive computations:

// Good - expensive filter/map operation
const filteredTasks = useMemo(() => {
return tasks
.filter((task) => task.status === selectedStatus)
.map((task) => transformTask(task));
}, [tasks, selectedStatus]);

// Bad - simple reference, not needed
const config = useMemo(() => ({ key: "value" }), []);
// Better: just move outside component or use constant
const config = { key: "value" };

useCallback​

Use for callbacks passed to memoized children or dependencies:

// Good - callback passed to memoized child
const handleClick = useCallback(
(id: string) => {
onSelect(id);
track("item_selected", { id });
},
[onSelect]
);

// Good - callback in useEffect dependency
const fetchData = useCallback(async () => {
const data = await api.get(endpoint);
setData(data);
}, [endpoint]);

useEffect(() => {
fetchData();
}, [fetchData]);

// Bad - simple handler not passed to children
const handleClick = useCallback(() => {
setIsOpen(true);
}, []); // Just use regular function

When NOT to Memoize​

  • Simple primitive operations
  • Callbacks only used in event handlers
  • State setters from useState
  • When the component re-renders infrequently anyway

Effect Hooks Best Practices​

useEffect Rules​

// 1. Always specify dependencies
useEffect(() => {
document.title = `${count} items`;
}, [count]); // Explicit dependency

// 2. Clean up subscriptions
useEffect(() => {
const subscription = eventSource.subscribe(handler);
return () => subscription.unsubscribe();
}, [eventSource]);

// 3. Avoid object/array literals in dependencies
// Bad
useEffect(() => {
fetchData({ page, limit });
}, [{ page, limit }]); // Creates new object every render!

// Good
useEffect(() => {
fetchData({ page, limit });
}, [page, limit]);

// 4. Use refs for values that shouldn't trigger re-renders
const latestValue = useRef(value);
useEffect(() => {
latestValue.current = value;
});

useLayoutEffect​

Use when you need to read layout and synchronously re-render:

// Measuring DOM elements
useLayoutEffect(() => {
const { height } = ref.current.getBoundingClientRect();
setHeight(height);
}, []);

Custom Hook Patterns​

Composing Hooks​

function useAgentWithTasks(agentId: string) {
const agent = useAgent(agentId);
const tasks = useAgentTasks(agentId);

return {
agent: agent.data,
tasks: tasks.data,
isLoading: agent.isLoading || tasks.isLoading,
isError: agent.isError || tasks.isError,
};
}

Hook with Options​

interface UseTasksOptions {
status?: TaskStatus;
limit?: number;
enabled?: boolean;
}

function useTasks(workspacePath: string, options: UseTasksOptions = {}) {
const { status, limit = 50, enabled = true } = options;

const params = new URLSearchParams({
workspace_path: workspacePath,
...(status && { status }),
limit: String(limit),
});

return useSWR(
enabled ? `/api/tasks?${params}` : null,
fetcher
);
}

Return Pattern​

Always return a consistent shape:

interface UseResourceResult<T> {
data: T | undefined;
isLoading: boolean;
isError: boolean;
error: Error | undefined;
refresh: () => void;
}

function useResource<T>(url: string): UseResourceResult<T> {
const { data, error, isLoading, mutate } = useSWR<T>(url, fetcher);

return {
data,
isLoading,
isError: !!error,
error,
refresh: mutate,
};
}