Skip to main content

Web Hooks Reference

Overview

The Ever Works web application uses a collection of custom React hooks to encapsulate reusable stateful logic. All hooks live in apps/web/src/lib/hooks/ and follow the use-*.ts naming convention. They are client-only ('use client') since they depend on browser APIs (localStorage, fetch, DOM events) and React state.

Architecture

apps/web/src/lib/hooks/
├── use-ai-stream.ts # Streaming AI responses via ReadableStream
├── use-chat-history.ts # Chat message state management
├── use-sidebar-persistence.ts # Sidebar width/collapsed localStorage persistence
├── use-keyboard-shortcuts.ts # Global keyboard shortcuts
├── use-plugin-settings.ts # Plugin settings form management
├── use-provider-selection.ts # AI/search/screenshot provider selection
├── use-theme.ts # Dark/light theme with system preference
├── use-plugin-toggle.ts # Plugin enable/disable with optimistic updates
├── use-local-storage.ts # Generic localStorage hook (SSR-safe)
└── use-mounted.ts # Mounted state for hydration safety

Components

useAIStream

File: apps/web/src/lib/hooks/use-ai-stream.ts

Signature:

function useAIStream(options: {
onChunk?: (chunk: unknown) => void;
onComplete?: (content: string) => void;
onError?: (error: Error) => void;
}): {
streamMessage: (url: string, body: unknown) => Promise<void>;
isStreaming: boolean;
content: string;
error: Error | null;
reset: () => void;
};
Return ValueTypeDescription
streamMessage(url: string, body: unknown) => Promise<void>Initiates a streaming request
isStreamingbooleanWhether a stream is currently active
contentstringAccumulated content from all chunks so far
errorError | nullError if the stream failed
reset() => voidResets content, error, and streaming state

This hook handles NDJSON (newline-delimited JSON) streaming responses. When streamMessage is called:

  1. A fetch POST request is sent to the given URL with the body as JSON.
  2. The response body ReadableStream is read with a TextDecoder.
  3. Incoming text is split on newlines and each line is parsed as JSON.
  4. The onChunk callback receives each parsed object.
  5. Content chunks are concatenated into the content state.
  6. When the stream ends, onComplete fires with the full content.
  7. On error, onError fires and the error is stored in state.
const { streamMessage, isStreaming, content, reset } = useAIStream({
onChunk: (chunk) => console.log('Received:', chunk),
onComplete: (fullContent) => saveMessage(fullContent),
onError: (err) => toast.error(err.message)
});

// Start streaming
await streamMessage('/api/ai-conversations/chat/stream', {
messages: conversationHistory,
providerId: selectedProvider
});

useChatHistory

File: apps/web/src/lib/hooks/use-chat-history.ts

Signature:

function useChatHistory(): {
messages: ChatMessage[];
error: string | null;
isLoading: boolean;
setMessages: (msgs: ChatMessage[]) => void;
loadHistory: () => Promise<void>;
resetHistory: () => void;
};
Return ValueTypeDescription
messagesChatMessage[]Current conversation messages
errorstring | nullError message if loading failed
isLoadingbooleanWhether history is being loaded
setMessages(msgs: ChatMessage[]) => voidReplace the entire message list
loadHistory() => Promise<void>Fetch conversation history from the server
resetHistory() => voidClear all messages and start fresh

Types:

type ChatMessageRole = 'user' | 'assistant' | 'system';

interface ChatMessage {
id: string;
role: ChatMessageRole;
content: string;
createdAt?: string;
}

This hook manages the chat message array. Messages are stored in React state and can be loaded from the server (for conversation persistence) or manipulated locally. It is consumed by ChatProvider to provide the message state to the entire chat UI tree.

const { messages, setMessages, loadHistory, resetHistory } = useChatHistory();

// Load existing conversation
useEffect(() => {
loadHistory();
}, []);

// Add a new user message
setMessages([...messages, { id: nanoid(), role: 'user', content: userInput }]);

useSidebarPersistence

File: apps/web/src/lib/hooks/use-sidebar-persistence.ts

Signature:

function useSidebarPersistence(): {
sidebarWidth: number;
sidebarCollapsed: boolean;
handleSidebarWidthChange: (width: number) => void;
handleSidebarCollapsedChange: (collapsed: boolean) => void;
};

Constants:

const SIDEBAR_WIDTH_DEFAULT = 320;
const SIDEBAR_WIDTH_MIN = 320; // exported
const SIDEBAR_WIDTH_MAX = 440; // exported
Return ValueTypeDescription
sidebarWidthnumberCurrent sidebar width in pixels
sidebarCollapsedbooleanWhether the sidebar is collapsed
handleSidebarWidthChange(width: number) => voidUpdate and persist width
handleSidebarCollapsedChange(collapsed: boolean) => voidUpdate and persist collapsed state

Wraps useLocalStorage with custom serializers:

  • Width is stored as a string key sidebar-width, parsed as parseInt(raw, 10), and validated against SIDEBAR_WIDTH_MIN and SIDEBAR_WIDTH_MAX. Invalid values fall back to the default (320).
  • Collapsed is stored as key sidebar-collapsed with values '1' (true) or '0' (false).

Both setters are wrapped in useCallback for stable references.

const { sidebarWidth, sidebarCollapsed, handleSidebarWidthChange, handleSidebarCollapsedChange } =
useSidebarPersistence();

useKeyboardShortcuts

File: apps/web/src/lib/hooks/use-keyboard-shortcuts.ts

Signature:

function useKeyboardShortcuts(options: { onOpenHelp?: () => void }): void;

Registers global keyboard event listeners on mount. The registered shortcuts are:

ShortcutAction
Ctrl+K (or Cmd+K)Open search
CCreate new work (only when no input is focused)
?Open help drawer (calls onOpenHelp)

The hook checks document.activeElement to avoid triggering shortcuts while the user is typing in an input, textarea, or contenteditable element. Listeners are cleaned up on unmount.

useKeyboardShortcuts({
onOpenHelp: () => setHelpDrawerOpen(true)
});

usePluginSettings

File: apps/web/src/lib/hooks/use-plugin-settings.ts

Signature:

function usePluginSettings(options: {
schema: JSONSchema;
initialSettings: Record<string, unknown>;
scopes?: string[];
onSave: (settings: Record<string, unknown>) => Promise<void>;
fallbackSettings?: Record<string, unknown>;
scope?: string;
}): {
settings: Record<string, unknown>;
errors: Record<string, string>;
isDirty: boolean;
isSaving: boolean;
handleFieldChange: (field: string, value: unknown) => void;
handleSave: () => Promise<void>;
resetSettings: () => void;
};
Return ValueTypeDescription
settingsRecord<string, unknown>Current settings values
errorsRecord<string, string>Validation errors per field
isDirtybooleanWhether settings have been modified
isSavingbooleanWhether a save operation is in progress
handleFieldChange(field: string, value: unknown) => voidUpdate a single field
handleSave() => Promise<void>Validate and save settings
resetSettings() => voidReset to initial values

This hook manages a dynamic form generated from a JSON Schema. It:

  1. Initializes field values from initialSettings, falling back to fallbackSettings.
  2. Tracks changes to mark the form as dirty.
  3. Validates fields against the schema (required fields, pattern matching, etc.).
  4. On save, sanitizes the settings (trims strings, removes empty values) and calls onSave.
  5. Supports scoped settings where fields belong to specific capability scopes.
const { settings, errors, isDirty, handleFieldChange, handleSave } = usePluginSettings({
schema: plugin.settingsSchema,
initialSettings: currentSettings,
onSave: async (newSettings) => {
await updatePluginSettings(plugin.id, newSettings);
}
});

useProviderSelection

File: apps/web/src/lib/hooks/use-provider-selection.ts

Signature:

function useProviderSelection(initial?: ProviderSelectionState): {
providers: ProviderSelectionState;
handleProviderChange: (field: string, value: string) => void;
buildSelectedProviders: () => SelectedProviders;
getUnconfiguredProviders: () => string[];
syncResolvedPipeline: (pipeline: ResolvedPipeline) => void;
};
Return ValueTypeDescription
providersProviderSelectionStateCurrent provider selections
handleProviderChange(field: string, value: string) => voidUpdate a provider selection
buildSelectedProviders() => SelectedProvidersBuild the final provider config object
getUnconfiguredProviders() => string[]List providers that need configuration
syncResolvedPipeline(pipeline: ResolvedPipeline) => voidSync state with a server-resolved pipeline

Manages the state for selecting AI providers, search providers, and screenshot providers. Used by ChatProvider and the generator configuration form. The buildSelectedProviders method assembles the final object sent to the backend when starting a generation or chat session.

useTheme

File: apps/web/src/lib/hooks/use-theme.ts

Signature:

function useTheme(): {
theme: 'light' | 'dark';
isDark: boolean;
toggleTheme: () => void;
mounted: boolean;
};
Return ValueTypeDescription
theme'light' | 'dark'Current theme
isDarkbooleanConvenience boolean for dark mode checks
toggleTheme() => voidSwitch between light and dark
mountedbooleanWhether the component has mounted (for SSR safety)

Manages the theme state with three layers:

  1. localStorage - Persists the user's explicit choice under a theme key.
  2. System preference - Respects prefers-color-scheme: dark media query if no explicit choice is stored.
  3. DOM class - Toggles the dark class on the <html> element to activate Tailwind's dark mode variants.

The mounted flag prevents hydration mismatches by defaulting to a safe value during SSR and updating after mount.

const { isDark, toggleTheme, mounted } = useTheme();

if (!mounted) return null; // Prevent hydration flash

return <button onClick={toggleTheme}>{isDark ? <Sun /> : <Moon />}</button>;

usePluginToggle

File: apps/web/src/lib/hooks/use-plugin-toggle.ts

Signature:

function usePluginToggle(options: { pluginId: string; enabled: boolean; visibility?: 'public' | 'private' }): {
isEnabled: boolean;
isPending: boolean;
handleToggle: (newState: boolean) => void;
showEnablePanel: boolean;
showDisableWarning: boolean;
};
Return ValueTypeDescription
isEnabledbooleanCurrent enabled state (optimistic)
isPendingbooleanWhether a toggle operation is in progress
handleToggle(newState: boolean) => voidToggle the plugin on/off
showEnablePanelbooleanWhether to show the enable confirmation panel
showDisableWarningbooleanWhether to show the disable warning

Implements optimistic UI updates for plugin enable/disable:

  1. When toggled, the local state updates immediately.
  2. The server action is called in the background.
  3. If the server action fails, the local state is rolled back.
  4. A warning is shown when disabling a plugin that may affect active works.

Implementation Details

SSR Safety

All hooks that access browser APIs (localStorage, window, document) handle SSR safely:

  • useLocalStorage returns the default value during SSR and hydrates from localStorage after mount.
  • useTheme uses a mounted flag to prevent rendering theme-dependent UI before hydration.
  • useKeyboardShortcuts only attaches event listeners in a useEffect (which does not run during SSR).

The useLocalStorage Foundation

Several hooks (useSidebarPersistence, useTheme) build on useLocalStorage:

function useLocalStorage<T>(
key: string,
defaultValue: T,
options?: {
serialize?: (value: T) => string;
deserialize?: (raw: string) => T;
validate?: (value: T) => boolean;
}
): [T, (value: T) => void];

This hook reads from and writes to localStorage with custom serialization/deserialization and optional validation. Invalid values (or missing keys) fall back to defaultValue.

Streaming Architecture

The useAIStream hook is the client-side counterpart to the server's streaming API. The flow:

Styling & Theming

Hooks themselves do not contain styling, but they enable styling behaviors:

  • useTheme controls the dark class on <html>, enabling all dark: Tailwind variants project-wide.
  • useSidebarPersistence provides the width value that is applied as an inline style={{ width }} on the sidebar element.

Usage Examples

Custom Component with Multiple Hooks

'use client';

import { useTheme } from '@/lib/hooks/use-theme';
import { useKeyboardShortcuts } from '@/lib/hooks/use-keyboard-shortcuts';
import { useSidebarPersistence } from '@/lib/hooks/use-sidebar-persistence';

export function AppShell({ children }) {
const { isDark, toggleTheme, mounted } = useTheme();
const { sidebarWidth, sidebarCollapsed } = useSidebarPersistence();

useKeyboardShortcuts({
onOpenHelp: () => setHelpOpen(true)
});

if (!mounted) return null;

return (
<div className={isDark ? 'dark' : ''}>
<aside style={{ width: sidebarCollapsed ? 64 : sidebarWidth }}>{/* sidebar content */}</aside>
<main>{children}</main>
</div>
);
}

Streaming Chat with useAIStream

'use client';

import { useState } from 'react';
import { useAIStream } from '@/lib/hooks/use-ai-stream';

export function SimpleChat() {
const [messages, setMessages] = useState([]);
const { streamMessage, isStreaming, content, reset } = useAIStream({
onComplete: (fullContent) => {
setMessages((prev) => [...prev, { role: 'assistant', content: fullContent }]);
reset();
}
});

const handleSend = async (text: string) => {
setMessages((prev) => [...prev, { role: 'user', content: text }]);
await streamMessage('/api/ai-conversations/chat/stream', {
messages: [...messages, { role: 'user', content: text }]
});
};

return (
<div>
{messages.map((msg, i) => (
<div key={i}>{msg.content}</div>
))}
{isStreaming && <div>{content}</div>}
</div>
);
}