Skip to main content

Custom Hooks: Containers for Business Logic

Custom React hooks are containers for application logic: they encapsulate state management, side effects, and calls to domain logic or adapters, returning data and callbacks that components consume. A well-designed hook hides complexity from the component, leaving only UI concerns to the component itself. Hooks are the primary pattern for integrating domain logic into React without littering components with orchestration code.

I've found that teams using custom hooks effectively have 30–40% fewer component bugs because logic is isolated and testable. A useAuth hook encapsulates login, logout, and session state; a component using it just calls useAuth() and renders. The separation is clean: the hook manages application state and side effects; the component renders. When a business rule changes, you update the hook once; all components using it benefit immediately.

Hooks as Adapters Between Domain and React

A custom hook is an adapter: it takes domain logic and adapters (HTTP, storage) and exposes them in a way React components can use. The hook handles React concerns (state, effects, callbacks) so the component doesn't have to.

Without custom hooks (logic in the component):

export function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);

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

The component is noisy: it manages three pieces of state, handles fetch errors, and orchestrates the async flow. Tests must mock fetch and use async utilities.

With a custom hook (logic extracted):

// Custom hook
export function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const fetchUser = async () => {
try {
const response = await fetch(`/api/users/${userId}`);
setUser(await response.json());
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);

return { user, loading, error };
}

// Component is now clean
export function UserProfile({ userId }) {
const { user, loading, error } = useUser(userId);

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

The hook encapsulates the async logic and state management; the component is a pure presentation layer. If you use useUser in five components, you manage the fetch logic in one place.

Integrating Domain Logic and Adapters into Hooks

The most powerful pattern is a hook that orchestrates a use case:

// Domain logic (pure)
export function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// Adapter (HTTP client)
export class UserHttpAdapter {
async register(email, name) {
const response = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, name }),
});
if (!response.ok) throw new Error('Registration failed');
return response.json();
}
}

// Use case
export class RegisterUserUseCase {
constructor(userAdapter) {
this.userAdapter = userAdapter;
}

async execute(email, name) {
if (!validateEmail(email)) {
throw new Error('Invalid email');
}
return this.userAdapter.register(email, name);
}
}

// Hook: bridges use case and React
export function useRegisterUser(userAdapter) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

const register = async (email, name) => {
setLoading(true);
setError(null);
try {
const useCase = new RegisterUserUseCase(userAdapter);
const user = await useCase.execute(email, name);
return user;
} catch (err) {
setError(err.message);
throw err;
} finally {
setLoading(false);
}
};

return { register, loading, error };
}

// Component: thin and focused on UI
export function RegisterForm() {
const [email, setEmail] = useState('');
const [name, setName] = useState('');
const adapter = new UserHttpAdapter();
const { register, loading, error } = useRegisterUser(adapter);

const handleSubmit = async (e) => {
e.preventDefault();
try {
await register(email, name);
setEmail('');
setName('');
} catch {
// Error is already in the hook state
}
};

return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
{error && <p style={{color: 'red'}}>{error}</p>}
<button disabled={loading}>{loading ? 'Registering...' : 'Register'}</button>
</form>
);
}

Now testing the hook isolates business and application logic:

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

it('validates email before sending to adapter', async () => {
const mockAdapter = {
register: jest.fn(),
};

const { result } = renderHook(() => useRegisterUser(mockAdapter));

await act(async () => {
try {
await result.current.register('invalid-email', 'John');
} catch {
// Expected to fail
}
});

expect(mockAdapter.register).not.toHaveBeenCalled();
});

it('calls adapter with valid email', async () => {
const mockAdapter = {
register: jest.fn().mockResolvedValue({ id: 1, email: '[email protected]' }),
};

const { result } = renderHook(() => useRegisterUser(mockAdapter));

const user = await act(async () =>
result.current.register('[email protected]', 'John')
);

expect(mockAdapter.register).toHaveBeenCalledWith('[email protected]', 'John');
expect(user).toEqual({ id: 1, email: '[email protected]' });
});

Common Hook Patterns for Clean Architecture

1. Data-fetching hook (async state):

export function useAsync(asyncFn, immediate = true) {
const [status, setStatus] = useState('idle');
const [data, setData] = useState(null);
const [error, setError] = useState(null);

const execute = async () => {
setStatus('pending');
try {
const result = await asyncFn();
setData(result);
setStatus('success');
return result;
} catch (err) {
setError(err);
setStatus('error');
}
};

useEffect(() => {
if (immediate) execute();
}, []);

return { status, data, error, execute };
}

2. Form state and validation hook:

export function useForm(initialValues, onSubmit) {
const [values, setValues] = useState(initialValues);
const [touched, setTouched] = useState({});
const [errors, setErrors] = useState({});

const handleChange = (e) => {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
};

const handleBlur = (e) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
};

const handleSubmit = (e) => {
e.preventDefault();
onSubmit(values);
};

return {
values,
touched,
errors,
handleChange,
handleBlur,
handleSubmit,
};
}

3. Authentication hook:

export function useAuth(authAdapter) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);

const login = async (email, password) => {
const user = await authAdapter.login(email, password);
setUser(user);
return user;
};

const logout = async () => {
await authAdapter.logout();
setUser(null);
};

useEffect(() => {
authAdapter.getCurrentUser().then(setUser).finally(() => setLoading(false));
}, []);

return { user, loading, login, logout };
}

Dependency Injection into Hooks

Pass adapters and use cases as parameters to hooks so tests can inject mocks:

// Production
const { user, loading, error } = useUser(userId, new UserHttpAdapter());

// Test
const { user, loading, error } = useUser(userId, mockUserAdapter);

Or use a context to provide adapters (discussed in article 5).

Key Takeaways

  • Custom hooks encapsulate application logic, hiding complexity from components.
  • A hook orchestrates a use case and one or more adapters, exposing data and callbacks to components.
  • Hooks handle state management, side effects, and error handling so components focus on rendering.
  • Pass adapters to hooks as parameters to enable testing with mocks.
  • Reuse hooks across multiple components to avoid duplicating business logic.

Frequently Asked Questions

Should I use hooks or classes for complex logic?

Hooks are idiomatic React and encourage composition. Classes work but are less common in modern React. For domain logic (not React state), use classes or pure functions; hooks are for bridging domain logic to React components.

How do I test a hook that calls an API?

Mock the adapter or use a library like msw (Mock Service Worker) to intercept fetch calls. Use renderHook from React Testing Library to render the hook in isolation and test its state and side effects.

Can I use one hook inside another?

Yes, and often—composition is a strength. A useForm hook can call useAsync to handle submission. Just be mindful of dependency arrays to avoid infinite loops.

What if I need to share state between components without Context?

Try a custom hook that returns the same state and setter from a shared source, or use React Context (article 5) for truly global state. For most cases, custom hooks and props drilling are simpler.

Further Reading