React Native has become a popular framework for building mobile applications using JavaScript and React. One of its powerful features is the ability to create custom hooks, which allow developers to reuse logic across different components efficiently. This article explores how to build custom hooks for reusable React Native logic, making your code cleaner and more maintainable. By the end, you will have a deep understanding of custom hooks, best practices for their creation, and advanced patterns that integrate seamlessly with external services like Directus, a headless CMS.

Understanding Custom Hooks in React Native

Custom hooks are JavaScript functions that start with the prefix use. They leverage React's built-in hooks like useState, useEffect, and useContext to encapsulate common logic. By creating custom hooks, developers can share functionality such as fetching data, handling form inputs, managing subscriptions, or even interacting with native device APIs across multiple components.

The power of custom hooks lies in their ability to abstract complex stateful logic into a single callable unit. This abstraction leads to cleaner component code, easier testing, and enhanced reusability. In the React Native ecosystem, where performance and code organization are critical, custom hooks are an indispensable tool.

When to Create a Custom Hook

Not every repeated code block justifies a custom hook. The ideal candidates are patterns that involve state management, side effects, or context interactions. Common indicators include:

  • Repeated state logic: Multiple components use the same useState or useReducer pattern.
  • Shared side effects: Several components subscribe to the same external data source (API, WebSocket, Device sensors).
  • Complex context consumption: Accessing and transforming context values repeatedly across components.
  • Platform-specific logic: Abstracting React Native’s platform APIs (e.g., geolocation, accelerometer) behind a consistent interface.

For example, if you find yourself writing fetch + useEffect in three different screens, it’s time to extract that logic into a custom hook.

Step-by-Step Guide to Building a Custom Hook

Step 1: Identify the Reusable Logic

Look for code that appears in multiple components with minor variations. The logic should be self-contained and ideally not dependent on the visual rendering of the component. Good candidates include API calls, form validation, debounced inputs, and persistent state management with AsyncStorage.

Step 2: Create the Hook Function

Name the function with the use prefix (e.g., useWeatherData, useFormValidation). Inside, you can use any React hooks, but remember the Rules of Hooks: only call hooks at the top level of your function and only from React functions.

Step 3: Manage State and Effects

Use useState for local state, useEffect for side effects, and useMemo/useCallback for performance optimizations. Ensure cleanup functions in useEffect to avoid memory leaks.

Step 4: Return Values and Functions

The hook should return an object or array containing the state, data, and any utility functions. Components will destructure these to access the hook’s contract. Keep the return interface minimal and intuitive.

Step 5: Test in Isolation

Before integrating, test the hook in a simple component or using React Testing Library. This step verifies the logic works independently of any specific UI.

Example: A Production‑Ready Data‑Fetching Hook

Building on the basic example from the introduction, here is a more robust version that includes request cancellation, error normalization, and support for multiple HTTP methods:

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

function useRequest(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const abortController = useRef(null);

  useEffect(() => {
    const fetchData = async () => {
      abortController.current = new AbortController();
      setLoading(true);
      try {
        const response = await fetch(url, {
          ...options,
          signal: abortController.current.signal,
        });
        if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
        const json = await response.json();
        setData(json);
        setError(null);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setData(null);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    return () => {
      if (abortController.current) {
        abortController.current.abort();
      }
    };
  }, [url, JSON.stringify(options)]);

  const refetch = () => {
    setLoading(true);
    setError(null);
    setData(null);
    // trigger re-run effect via a state change
  };

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

This hook handles cleanup when the component unmounts, prevents setting state on unmounted components, and exposes a refetch function. It can be used in any React Native screen or component.

Integrating Custom Hooks with Directus SDK

Directus is a headless CMS that provides a RESTful and GraphQL API. When building React Native apps with Directus as the backend, custom hooks can encapsulate the SDK calls, manage authentication tokens, and handle caching. Below is an example of a hook that fetches a collection from Directus:

import { useState, useEffect } from 'react';
import { createDirectus, rest } from '@directus/sdk';

const client = createDirectus('https://your-project.directus.app')
  .with(rest());

function useDirectusCollection(collection, options = {}) {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchItems = async () => {
      setLoading(true);
      try {
        const response = await client.request(
          client.items(collection).readByQuery(options)
        );
        setItems(response.data);
        setError(null);
      } catch (err) {
        setError(err.message);
        setItems([]);
      } finally {
        setLoading(false);
      }
    };
    fetchItems();
  }, [collection, JSON.stringify(options)]);

  return { items, loading, error };
}

By using this hook across your app, you centralize Directus API interaction. When Directus updates its SDK or your API changes, you only need to modify one hook instead of every screen.

Handling Authentication with a Custom Hook

In many React Native apps, you need to manage authentication tokens, refresh them, and persist them across sessions. A custom hook like useDirectusAuth can abstract the login, logout, and token refresh logic:

import { useState, useEffect, useCallback } from 'react';
import { createDirectus, authentication } from '@directus/sdk';
import AsyncStorage from '@react-native-async-storage/async-storage';

const client = createDirectus('https://your-project.directus.app')
  .with(authentication('json'));

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

  useEffect(() => {
    const restoreToken = async () => {
      try {
        const token = await AsyncStorage.getItem('auth_token');
        if (token) {
          client.setToken(token);
          const me = await client.me();
          setUser(me);
        }
      } catch (err) {
        console.warn('Failed to restore auth token', err);
      } finally {
        setLoading(false);
      }
    };
    restoreToken();
  }, []);

  const login = useCallback(async (email, password) => {
    const response = await client.login(email, password);
    await AsyncStorage.setItem('auth_token', response.access_token);
    const me = await client.me();
    setUser(me);
  }, []);

  const logout = useCallback(async () => {
    await client.logout();
    await AsyncStorage.removeItem('auth_token');
    setUser(null);
  }, []);

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

This hook centralizes token storage and user state, making authentication management consistent across your entire app.

Advanced Patterns for Custom Hooks

useReducer for Complex State

When a hook manages multiple interdependent state values, consider useReducer instead of multiple useState calls. This pattern improves readability and makes state transitions more predictable.

import { useReducer, useEffect } from 'react';

const initialState = { data: null, loading: true, error: null };

function reducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { data: action.payload, loading: false, error: null };
    case 'FETCH_ERROR':
      return { data: null, loading: false, error: action.payload };
    case 'RESET':
      return initialState;
    default:
      return state;
  }
}

function useDataFetch(url) {
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCH_START' });
    fetch(url)
      .then(res => res.json())
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
  }, [url]);

  return state;
}

Composing Hooks

Hooks can call other hooks. For example, a useUserProfile hook might internally use useDirectusAuth and useRequest to fetch additional profile data. This composition keeps each hook focused on a single responsibility.

Performance Optimizations with useMemo and useCallback

If your custom hook returns derived data or functions that depend on state, wrap them in useMemo and useCallback to avoid unnecessary re-renders in consuming components. For instance:

function useSortedPosts(posts) {
  const sortedPosts = useMemo(() => {
    return [...posts].sort((a, b) => new Date(b.created) - new Date(a.created));
  }, [posts]);
  return sortedPosts;
}

Testing Custom Hooks

Unit testing custom hooks ensures they behave correctly across different scenarios. Use renderHook from @testing-library/react-hooks (or React Native Testing Library) to test hooks in isolation:

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

test('should return loading true initially', () => {
  const { result } = renderHook(() => useFetch('/api/test'));
  expect(result.current.loading).toBe(true);
});

test('should return data after success', async () => {
  const { result, waitForNextUpdate } = renderHook(() => useFetch('/api/test'));
  await waitForNextUpdate();
  expect(result.current.data).toBeDefined();
});

Remember to mock network requests using libraries like msw (Mock Service Worker) to avoid real API calls during testing.

Best Practices for Production‑Ready Hooks

  • Keep hooks focused: Each hook should do one thing well. If a hook grows too large, split it into smaller hooks.
  • Return stable references: Use useRef for values that shouldn’t trigger re-renders and useCallback for functions passed to children.
  • Handle cleanup: Always return cleanup functions in useEffect to cancel subscriptions, abort fetches, or remove event listeners.
  • Document the interface: Write clear contract comments for the hook’s parameters and return values. This aids future maintainers.
  • Use TypeScript: Strongly type the hook’s parameters and return type to catch errors early and improve developer experience.
  • Avoid over‑abstraction: Not every small piece of logic merits a hook. Premature extraction can make the codebase harder to follow.

Common Pitfalls and How to Avoid Them

  • Mutating props or state: Always treat state as immutable. Use functions like setState and spread operators correctly.
  • Missing dependency arrays: Omitting dependencies in useEffect or useMemo leads to stale closures. Use the exhaustive-deps ESLint rule.
  • Inline functions in returned objects: This creates new references on every render, potentially causing unnecessary re-renders if consumed by React.memo components. Wrap in useCallback.
  • Overusing custom hooks for simple styling logic: Not all repeated code needs to be a hook; sometimes a utility function is sufficient.

Conclusion

Building custom hooks in React Native is a best practice for creating reusable, maintainable, and clean code. By encapsulating common logic into hooks, developers can streamline their development process and improve app performance. Start identifying repetitive patterns in your projects and create custom hooks to make your codebase more efficient and scalable. Whether you are fetching data from a Directus backend, managing authentication, or handling complex form states, custom hooks empower you to write less code with fewer bugs. Embrace the hook pattern, and you will find your React Native code becoming more modular, testable, and joyful to work with.

For further reading, consult the official React Native Hooks documentation and the Directus SDK authentication guide. Additionally, explore advanced patterns like useReducer in the official React docs and testing custom hooks to deepen your expertise.