Skip to main content

Custom Hooks

Learn how to create custom hooks that integrate with React SuperAdmin's architecture.

Hook Patterns​

Custom hooks should follow React's rules of hooks and integrate seamlessly with the framework's patterns.

Basic Hook Structure​

import { useState, useEffect, useCallback } from 'react';

interface UseCustomHookOptions {
enabled?: boolean;
interval?: number;
}

interface UseCustomHookReturn {
data: any;
loading: boolean;
error: Error | null;
refresh: () => void;
}

export const useCustomHook = (
options: UseCustomHookOptions = {}
): UseCustomHookReturn => {
const { enabled = true, interval = 5000 } = options;

const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);

const fetchData = useCallback(async () => {
try {
setLoading(true);
setError(null);
// Your data fetching logic here
const result = await api.get('/endpoint');
setData(result);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setLoading(false);
}
}, []);

const refresh = useCallback(() => {
fetchData();
}, [fetchData]);

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

useEffect(() => {
if (enabled && interval > 0) {
const timer = setInterval(fetchData, interval);
return () => clearInterval(timer);
}
}, [enabled, interval, fetchData]);

return { data, loading, error, refresh };
};

Integration with Core Hooks​

Extending useResource​

import { useResource } from '@react-superadmin/core';
import { useState, useCallback } from 'react';

export const useExtendedResource = (resourceName: string) => {
const resource = useResource(resourceName);
const [selectedItems, setSelectedItems] = useState<string[]>([]);

const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev =>
prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
);
}, []);

const selectAll = useCallback(() => {
setSelectedItems(resource.data?.map(item => item.id) || []);
}, [resource.data]);

const clearSelection = useCallback(() => {
setSelectedItems([]);
}, []);

const bulkDelete = useCallback(async () => {
if (selectedItems.length === 0) return;

try {
await Promise.all(selectedItems.map(id => resource.remove(id)));
setSelectedItems([]);
} catch (error) {
console.error('Bulk delete failed:', error);
}
}, [selectedItems, resource]);

return {
...resource,
selectedItems,
toggleSelection,
selectAll,
clearSelection,
bulkDelete,
hasSelection: selectedItems.length > 0,
selectionCount: selectedItems.length,
};
};

Extending useTable​

import { useTable } from '@react-superadmin/core';
import { useState, useCallback, useMemo } from 'react';

export const useEnhancedTable = (resourceName: string) => {
const table = useTable(resourceName);
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [bulkActions, setBulkActions] = useState<string[]>([]);

const toggleRowSelection = useCallback((id: string) => {
setSelectedRows(prev => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);

const selectAllRows = useCallback(() => {
setSelectedRows(new Set(table.data?.map(item => item.id) || []));
}, [table.data]);

const clearRowSelection = useCallback(() => {
setSelectedRows(new Set());
}, []);

const selectedData = useMemo(() => {
return table.data?.filter(item => selectedRows.has(item.id)) || [];
}, [table.data, selectedRows]);

const isAllSelected = useMemo(() => {
return table.data?.length > 0 && selectedRows.size === table.data.length;
}, [table.data, selectedRows]);

const isIndeterminate = useMemo(() => {
return (
selectedRows.size > 0 && selectedRows.size < (table.data?.length || 0)
);
}, [table.data, selectedRows]);

return {
...table,
selectedRows: Array.from(selectedRows),
selectedData,
toggleRowSelection,
selectAllRows,
clearRowSelection,
isAllSelected,
isIndeterminate,
hasSelection: selectedRows.size > 0,
selectionCount: selectedRows.size,
};
};

Data Fetching Hooks​

useApiQuery​

import { useState, useEffect, useCallback } from 'react';

interface UseApiQueryOptions<T> {
enabled?: boolean;
refetchOnWindowFocus?: boolean;
retry?: number;
retryDelay?: number;
}

export const useApiQuery = <T>(
queryKey: string,
queryFn: () => Promise<T>,
options: UseApiQueryOptions<T> = {}
) => {
const {
enabled = true,
refetchOnWindowFocus = false,
retry = 3,
retryDelay = 1000,
} = options;

const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [retryCount, setRetryCount] = useState(0);

const executeQuery = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await queryFn();
setData(result);
setRetryCount(0);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));

if (retryCount < retry) {
setTimeout(() => {
setRetryCount(prev => prev + 1);
executeQuery();
}, retryDelay);
}
} finally {
setLoading(false);
}
}, [queryFn, retry, retryDelay, retryCount]);

const refetch = useCallback(() => {
executeQuery();
}, [executeQuery]);

useEffect(() => {
if (enabled) {
executeQuery();
}
}, [enabled, executeQuery]);

useEffect(() => {
if (refetchOnWindowFocus) {
const handleFocus = () => refetch();
window.addEventListener('focus', handleFocus);
return () => window.removeEventListener('focus', handleFocus);
}
}, [refetchOnWindowFocus, refetch]);

return { data, loading, error, refetch, retryCount };
};

useInfiniteScroll​

import { useState, useEffect, useCallback, useRef } from 'react';

interface UseInfiniteScrollOptions {
threshold?: number;
rootMargin?: string;
}

export const useInfiniteScroll = <T>(
fetchNextPage: () => Promise<T[]>,
options: UseInfiniteScrollOptions = {}
) => {
const { threshold = 0.1, rootMargin = '100px' } = options;

const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);

const observerRef = useRef<IntersectionObserver>();
const lastElementRef = useRef<HTMLDivElement>(null);

const loadNextPage = useCallback(async () => {
if (loading || !hasMore) return;

try {
setLoading(true);
const newData = await fetchNextPage();

if (newData.length === 0) {
setHasMore(false);
} else {
setData(prev => [...prev, ...newData]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('Failed to load next page:', error);
} finally {
setLoading(false);
}
}, [fetchNextPage, loading, hasMore]);

useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadNextPage();
}
},
{ threshold, rootMargin }
);

observerRef.current = observer;

if (lastElementRef.current) {
observer.observe(lastElementRef.current);
}

return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [loadNextPage, hasMore, loading, threshold, rootMargin]);

const reset = useCallback(() => {
setData([]);
setPage(1);
setHasMore(true);
setLoading(false);
}, []);

return {
data,
loading,
hasMore,
page,
loadNextPage,
reset,
lastElementRef,
};
};

Form Hooks​

useFormValidation​

import { useState, useCallback } from 'react';

interface ValidationRule {
required?: boolean;
minLength?: number;
maxLength?: number;
pattern?: RegExp;
custom?: (value: any) => string | null;
}

interface ValidationRules {
[key: string]: ValidationRule;
}

export const useFormValidation = <T extends Record<string, any>>(
initialValues: T,
validationRules: ValidationRules
) => {
const [values, setValues] = useState<T>(initialValues);
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});

const validateField = useCallback(
(name: keyof T, value: any): string | null => {
const rule = validationRules[name as string];
if (!rule) return null;

if (rule.required && !value) {
return 'This field is required';
}

if (rule.minLength && value && value.length < rule.minLength) {
return `Minimum length is ${rule.minLength} characters`;
}

if (rule.maxLength && value && value.length > rule.maxLength) {
return `Maximum length is ${rule.maxLength} characters`;
}

if (rule.pattern && value && !rule.pattern.test(value)) {
return 'Invalid format';
}

if (rule.custom) {
return rule.custom(value);
}

return null;
},
[validationRules]
);

const validateForm = useCallback((): boolean => {
const newErrors: Partial<Record<keyof T, string>> = {};
let isValid = true;

Object.keys(validationRules).forEach(key => {
const fieldName = key as keyof T;
const error = validateField(fieldName, values[fieldName]);
if (error) {
newErrors[fieldName] = error;
isValid = false;
}
});

setErrors(newErrors);
return isValid;
}, [values, validationRules, validateField]);

const setFieldValue = useCallback(
(name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }));

if (touched[name]) {
const error = validateField(name, value);
setErrors(prev => ({ ...prev, [name]: error || undefined }));
}
},
[touched, validateField]
);

const setFieldTouched = useCallback(
(name: keyof T, isTouched = true) => {
setTouched(prev => ({ ...prev, [name]: isTouched }));

if (isTouched) {
const error = validateField(name, values[name]);
setErrors(prev => ({ ...prev, [name]: error || undefined }));
}
},
[values, validateField]
);

const resetForm = useCallback(() => {
setValues(initialValues);
setErrors({});
setTouched({});
}, [initialValues]);

return {
values,
errors,
touched,
setFieldValue,
setFieldTouched,
validateForm,
resetForm,
isValid: Object.keys(errors).length === 0,
};
};

State Management Hooks​

useLocalStorage​

import { useState, useEffect, useCallback } from 'react';

export const useLocalStorage = <T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] => {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});

const setValue = useCallback(
(value: T | ((prev: T) => T)) => {
try {
const valueToStore =
value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);

useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.error(
`Error parsing localStorage value for key "${key}":`,
error
);
}
}
};

window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);

return [storedValue, setValue];
};

useDebounce​

import { useState, useEffect } from 'react';

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

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

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

return debouncedValue;
};

Data Provider Hooks​

React SuperAdmin provides specialized hooks for working with data providers, making it easy to integrate data operations into your components.

useDataProvider​

The main hook for accessing the current data provider and switching between providers.

import { useDataProvider } from '@react-superadmin/web';

function UserList() {
const { dataProvider, switchProvider, getConfig } = useDataProvider();

// Access current provider
const currentProvider = getConfig().type;

// Switch providers
const switchToMock = () => {
switchProvider({ type: 'mock', options: { enableLogging: true } });
};

const switchToPrisma = () => {
switchProvider({ type: 'prisma', options: { enableCaching: true } });
};

// Use the provider
const fetchUsers = async () => {
const result = await dataProvider.getList('users', {
pagination: { page: 1, perPage: 10 }
});
return result.data;
};

return (
<div>
<p>Current provider: {currentProvider}</p>
<button onClick={switchToMock}>Use Mock</button>
<button onClick={switchToPrisma}>Use Prisma</button>
</div>
);
}

useDataProviderInstance​

A convenience hook that provides direct access to the data provider instance.

import { useDataProviderInstance } from '@react-superadmin/web';

function UserForm() {
const dataProvider = useDataProviderInstance();

const createUser = async (userData: any) => {
try {
const result = await dataProvider.create('users', { data: userData });
return result.data;
} catch (error) {
console.error('Failed to create user:', error);
throw error;
}
};

const updateUser = async (id: string, userData: any) => {
try {
const result = await dataProvider.update('users', { id, data: userData });
return result.data;
} catch (error) {
console.error('Failed to update user:', error);
throw error;
}
};

return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
</form>
);
}

Custom Data Provider Hooks​

You can create custom hooks that wrap data provider operations for specific resources.

import { useDataProviderInstance } from '@react-superadmin/web';
import { useState, useCallback } from 'react';

interface User {
id: string;
name: string;
email: string;
role: string;
}

export const useUsers = () => {
const dataProvider = useDataProviderInstance();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const fetchUsers = useCallback(
async (params?: any) => {
setLoading(true);
setError(null);

try {
const result = await dataProvider.getList<User>('users', {
pagination: { page: 1, perPage: 20 },
sort: { field: 'name', order: 'ASC' },
...params,
});

setUsers(result.data);
return result;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
},
[dataProvider]
);

const createUser = useCallback(
async (userData: Partial<User>) => {
setLoading(true);
setError(null);

try {
const result = await dataProvider.create<User>('users', {
data: userData,
});
await fetchUsers(); // Refresh the list
return result.data;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
},
[dataProvider, fetchUsers]
);

const updateUser = useCallback(
async (id: string, userData: Partial<User>) => {
setLoading(true);
setError(null);

try {
const result = await dataProvider.update<User>('users', {
id,
data: userData,
});
await fetchUsers(); // Refresh the list
return result.data;
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
},
[dataProvider, fetchUsers]
);

const deleteUser = useCallback(
async (id: string) => {
setLoading(true);
setError(null);

try {
await dataProvider.delete('users', { id });
await fetchUsers(); // Refresh the list
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error';
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
},
[dataProvider, fetchUsers]
);

return {
users,
loading,
error,
fetchUsers,
createUser,
updateUser,
deleteUser,
};
};

Hook Usage in Components​

function UserManagement() {
const {
users,
loading,
error,
fetchUsers,
createUser,
updateUser,
deleteUser
} = useUsers();

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

const handleCreateUser = async (userData: any) => {
try {
await createUser(userData);
// Show success message
} catch (error) {
// Handle error
}
};

if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;

return (
<div>
<h2>Users ({users.length})</h2>
{users.map(user => (
<div key={user.id}>
{user.name} - {user.email}
<button onClick={() => updateUser(user.id, { role: 'admin' })}>
Make Admin
</button>
<button onClick={() => deleteUser(user.id)}>Delete</button>
</div>
))}
</div>
);
}

Testing Custom Hooks​

Hook Testing​

import { renderHook, act } from '@testing-library/react';
import { useCustomHook } from './useCustomHook';

describe('useCustomHook', () => {
it('should return initial state', () => {
const { result } = renderHook(() => useCustomHook());

expect(result.current.data).toBe(null);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(null);
});

it('should fetch data when enabled', async () => {
const { result } = renderHook(() => useCustomHook({ enabled: true }));

expect(result.current.loading).toBe(true);

await act(async () => {
// Wait for async operations
});

expect(result.current.loading).toBe(false);
});

it('should handle errors', async () => {
// Mock API to throw error
const { result } = renderHook(() => useCustomHook());

await act(async () => {
// Trigger error condition
});

expect(result.current.error).toBeTruthy();
});
});

Best Practices​

  1. Follow React Rules: Always follow the rules of hooks
  2. Use TypeScript: Provide proper typing for all hooks
  3. Handle Errors: Implement proper error handling in async hooks
  4. Optimize Performance: Use useCallback and useMemo where appropriate
  5. Test Hooks: Write comprehensive tests for all custom hooks
  6. Documentation: Document parameters, return values, and usage examples
  7. Integration: Leverage existing core hooks when possible
  8. Naming: Use descriptive names that start with "use"

Examples​

Check out the Examples section to see these hooks in action, or explore the API Reference for detailed hook documentation.