Skip to main content

Custom Hooks

Reusable React hooks for common functionality.

WebSocket Hooks

useSocket

Manages Socket.io connection and event handling.

// lib/hooks/use-socket.ts

interface UseSocketOptions {
autoConnect?: boolean;
}

interface UseSocketReturn {
socket: Socket | null;
isConnected: boolean;
error: Error | null;
on: (event: string, callback: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void;
disconnect: () => void;
reconnect: () => void;
}

export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
const { autoConnect = true } = options;
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [error, setError] = useState<Error | null>(null);

useEffect(() => {
if (!autoConnect) return;

const socketInstance = io(process.env.NEXT_PUBLIC_SOCKET_URL, {
withCredentials: true,
transports: ['websocket', 'polling'],
});

socketInstance.on('connect', () => {
setIsConnected(true);
setError(null);
});

socketInstance.on('disconnect', () => {
setIsConnected(false);
});

socketInstance.on('connect_error', (err) => {
setError(err);
setIsConnected(false);
});

setSocket(socketInstance);

return () => {
socketInstance.disconnect();
};
}, [autoConnect]);

const on = useCallback((event: string, callback: (...args: any[]) => void) => {
socket?.on(event, callback);
return () => socket?.off(event, callback);
}, [socket]);

const emit = useCallback((event: string, ...args: any[]) => {
socket?.emit(event, ...args);
}, [socket]);

return {
socket,
isConnected,
error,
on,
emit,
disconnect: () => socket?.disconnect(),
reconnect: () => socket?.connect(),
};
}

useNotificationUpdates

Subscribe to real-time notifications.

interface NotificationEvent {
_id: string;
type: 'sync' | 'invitation' | 'system';
title: string;
message: string;
read: boolean;
createdAt: string;
}

export function useNotificationUpdates(
onNotification: (notification: NotificationEvent) => void
) {
const { on } = useSocket();

useEffect(() => {
return on('notification:new', onNotification);
}, [on, onNotification]);
}

Streaming Hook

useStreaming

Handles Server-Sent Events for streaming AI responses.

// lib/hooks/use-streaming.ts

type StreamingStatus = 'idle' | 'connecting' | 'streaming' | 'complete' | 'error';

interface Source {
title: string;
pageId: string;
url?: string;
excerpt?: string;
}

interface UseStreamingOptions {
onComplete?: (content: string, sources: Source[]) => void;
onError?: (error: Error) => void;
connectTimeout?: number; // Default: 30000ms
totalTimeout?: number; // Default: 120000ms
}

interface UseStreamingReturn {
content: string;
status: StreamingStatus;
sources: Source[];
isStreaming: boolean;
error: Error | null;
startStreaming: (question: string, conversationId?: string) => void;
stopStreaming: () => void;
reset: () => void;
}

export function useStreaming(options: UseStreamingOptions = {}): UseStreamingReturn {
const {
onComplete,
onError,
connectTimeout = 30000,
totalTimeout = 120000,
} = options;

const [content, setContent] = useState('');
const [status, setStatus] = useState<StreamingStatus>('idle');
const [sources, setSources] = useState<Source[]>([]);
const [error, setError] = useState<Error | null>(null);

const abortControllerRef = useRef<AbortController | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

const startStreaming = useCallback(async (question: string, conversationId?: string) => {
// Reset state
setContent('');
setSources([]);
setError(null);
setStatus('connecting');

// Create abort controller
abortControllerRef.current = new AbortController();

// Set connection timeout
const connectionTimeout = setTimeout(() => {
abortControllerRef.current?.abort();
setError(new Error('Connection timeout'));
setStatus('error');
}, connectTimeout);

try {
const response = await fetch('/api/v1/rag/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Workspace-Id': localStorage.getItem('activeWorkspaceId') || '',
},
body: JSON.stringify({ question, conversationId }),
signal: abortControllerRef.current.signal,
credentials: 'include',
});

clearTimeout(connectionTimeout);

if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}

setStatus('streaming');

// Set total timeout
timeoutRef.current = setTimeout(() => {
abortControllerRef.current?.abort();
setError(new Error('Stream timeout'));
setStatus('error');
}, totalTimeout);

const reader = response.body?.getReader();
const decoder = new TextDecoder();

if (!reader) {
throw new Error('No response body');
}

let buffer = '';

while (true) {
const { done, value } = await reader.read();

if (done) break;

buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';

for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));

switch (data.type) {
case 'status':
// Status update
break;
case 'sources':
setSources(data.sources);
break;
case 'chunk':
setContent((prev) => prev + data.text);
break;
case 'replace':
setContent(data.text);
break;
case 'done':
setStatus('complete');
onComplete?.(content, sources);
break;
case 'error':
throw new Error(data.message);
}
}
}
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
setStatus('error');
onError?.(err);
}
} finally {
clearTimeout(timeoutRef.current!);
}
}, [connectTimeout, totalTimeout, onComplete, onError]);

const stopStreaming = useCallback(() => {
abortControllerRef.current?.abort();
clearTimeout(timeoutRef.current!);
setStatus('idle');
}, []);

const reset = useCallback(() => {
stopStreaming();
setContent('');
setSources([]);
setError(null);
setStatus('idle');
}, [stopStreaming]);

return {
content,
status,
sources,
isStreaming: status === 'connecting' || status === 'streaming',
error,
startStreaming,
stopStreaming,
reset,
};
}

Permission Hooks

usePermissions

Check user permissions for current workspace.

// lib/hooks/use-permissions.ts

interface Permissions {
canQuery: boolean;
canViewSources: boolean;
canInvite: boolean;
canManageSync: boolean;
canEditSettings: boolean;
isWorkspaceOwner: boolean;
isWorkspaceMember: boolean;
hasGlobalRole: (role: 'admin' | 'user') => boolean;
hasWorkspaceRole: (role: 'owner' | 'admin' | 'member' | 'viewer') => boolean;
}

export function usePermissions(): Permissions {
const user = useAuthStore((s) => s.user);
const workspace = useActiveWorkspace();

const permissions = useMemo(() => {
const wsPermissions = workspace?.permissions ?? {
canQuery: false,
canViewSources: false,
canInvite: false,
canManageSync: false,
canEditSettings: false,
};

return {
...wsPermissions,
isWorkspaceOwner: workspace?.role === 'owner',
isWorkspaceMember: !!workspace,
hasGlobalRole: (role: 'admin' | 'user') => user?.role === role,
hasWorkspaceRole: (role: 'owner' | 'admin' | 'member' | 'viewer') =>
workspace?.role === role,
};
}, [user, workspace]);

return permissions;
}

useRequireAuth

Redirect to login if not authenticated.

export function useRequireAuth(redirectTo = '/login') {
const router = useRouter();
const { isAuthenticated, isInitialized } = useAuthStore();

useEffect(() => {
if (isInitialized && !isAuthenticated) {
router.replace(redirectTo);
}
}, [isAuthenticated, isInitialized, redirectTo, router]);

return { isLoading: !isInitialized };
}

useRequireWorkspace

Redirect if no workspace selected.

export function useRequireWorkspace(redirectTo = '/workspaces') {
const router = useRouter();
const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId);
const isLoading = useWorkspaceStore((s) => s.isLoading);

useEffect(() => {
if (!isLoading && !activeWorkspaceId) {
router.replace(redirectTo);
}
}, [activeWorkspaceId, isLoading, redirectTo, router]);

return { isLoading };
}

Utility Hooks

useDebounce

Debounce a value with configurable delay.

export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);

useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => clearTimeout(timer);
}, [value, delay]);

return debouncedValue;
}

// Usage
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300);

useEffect(() => {
if (debouncedQuery) {
searchApi.search(debouncedQuery);
}
}, [debouncedQuery]);
}

useLocalStorage

Sync state with localStorage.

export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;

try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});

const setValue = useCallback((value: T) => {
setStoredValue(value);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(value));
}
}, [key]);

return [storedValue, setValue];
}

useMediaQuery

Responsive breakpoint detection.

export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);

useEffect(() => {
const mediaQuery = window.matchMedia(query);
setMatches(mediaQuery.matches);

const handler = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};

mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [query]);

return matches;
}

// Usage
function ResponsiveComponent() {
const isMobile = useMediaQuery('(max-width: 768px)');
const isTablet = useMediaQuery('(max-width: 1024px)');

return isMobile ? <MobileView /> : <DesktopView />;
}

useCopyToClipboard

Copy text to clipboard with feedback.

interface UseCopyToClipboardReturn {
copied: boolean;
copy: (text: string) => Promise<void>;
reset: () => void;
}

export function useCopyToClipboard(timeout = 2000): UseCopyToClipboardReturn {
const [copied, setCopied] = useState(false);

const copy = useCallback(async (text: string) => {
await navigator.clipboard.writeText(text);
setCopied(true);
}, []);

const reset = useCallback(() => {
setCopied(false);
}, []);

useEffect(() => {
if (copied) {
const timer = setTimeout(() => setCopied(false), timeout);
return () => clearTimeout(timer);
}
}, [copied, timeout]);

return { copied, copy, reset };
}

// Usage
function CodeBlock({ code }: { code: string }) {
const { copied, copy } = useCopyToClipboard();

return (
<div>
<pre>{code}</pre>
<Button onClick={() => copy(code)}>
{copied ? 'Copied!' : 'Copy'}
</Button>
</div>
);
}

useOnClickOutside

Detect clicks outside an element.

export function useOnClickOutside(
ref: RefObject<HTMLElement>,
handler: (event: MouseEvent | TouchEvent) => void
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
if (!ref.current || ref.current.contains(event.target as Node)) {
return;
}
handler(event);
};

document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);

return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]);
}

// Usage
function Dropdown() {
const ref = useRef<HTMLDivElement>(null);
const [isOpen, setIsOpen] = useState(false);

useOnClickOutside(ref, () => setIsOpen(false));

return (
<div ref={ref}>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && <DropdownContent />}
</div>
);
}

Hook Composition

Combine hooks for complex features:

// Custom hook for chat functionality
export function useChat(conversationId?: string) {
const { startStreaming, content, status, sources, reset } = useStreaming({
onComplete: (content, sources) => {
// Save message to conversation
createMessage.mutate({ content, sources });
},
});

const createMessage = useMutation({
mutationFn: (data: CreateMessageInput) =>
conversationsApi.createMessage(conversationId!, data),
onSuccess: () => {
queryClient.invalidateQueries(['conversation', conversationId]);
},
});

const sendMessage = useCallback((question: string) => {
startStreaming(question, conversationId);
}, [startStreaming, conversationId]);

return {
sendMessage,
content,
status,
sources,
reset,
isLoading: status === 'connecting' || status === 'streaming',
};
}