onmyway133 / blog

🍁 What you don't know is what you haven't learned
https://onmyway133.com/
MIT License
679 stars 33 forks source link

How to use React Custom Hooks as the View Model pattern #975

Open onmyway133 opened 4 months ago

onmyway133 commented 4 months ago

When building a React application, separating the logic and state management from the UI can make your code easier to manage, test, and reuse. This is where the view model pattern comes in handy. By using a custom hook as a view model, you can keep your components focused on displaying the UI while the hook handles all the complex logic and state. Let's break down how to do this with a simple example.

Creating custom hook

A custom hook is a JavaScript function that uses React hooks like useState and useEffect to manage state and logic. In this example, we’ll create a custom hook to manage a list of items. The hook will handle fetching items from an API, adding new items, and removing items.

Here’s how you can create the custom hook:

// useItemsViewModel.js
import { useState, useEffect } from 'react';

const useItemsViewModel = () => {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  // Fetch items from an API
  const fetchItems = async () => {
    setLoading(true);
    try {
      const response = await fetch('/api/items');
      const data = await response.json();
      setItems(data);
    } catch (err) {
      setError(err.message);
    } finally {
      setLoading(false);
    }
  };

  // Add a new item
  const addItem = async (item) => {
    try {
      const response = await fetch('/api/items', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(item),
      });
      const newItem = await response.json();
      setItems((prevItems) => [...prevItems, newItem]);
    } catch (err) {
      setError(err.message);
    }
  };

  // Remove an item
  const removeItem = async (id) => {
    try {
      await fetch(`/api/items/${id}`, {
        method: 'DELETE',
      });
      setItems((prevItems) => prevItems.filter((item) => item.id !== id));
    } catch (err) {
      setError(err.message);
    }
  };

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

  return {
    items,
    loading,
    error,
    addItem,
    removeItem,
  };
};

export default useItemsViewModel;

In this hook:

Using the Custom Hook in a Component

Now, let's create a component that uses this custom hook to display the list of items and allows users to add or remove items.

// ItemsComponent.js
import React, { useState } from 'react';
import useItemsViewModel from './useItemsViewModel';

const ItemsComponent = () => {
  const { items, loading, error, addItem, removeItem } = useItemsViewModel();
  const [newItemName, setNewItemName] = useState('');

  const handleAddItem = () => {
    if (newItemName.trim()) {
      addItem({ name: newItemName });
      setNewItemName('');
    }
  };

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

  return (
    <div>
      <h1>Items</h1>
      <ul>
        {items.map((item) => (
          <li key={item.id}>
            {item.name} <button onClick={() => removeItem(item.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <input
        type="text"
        value={newItemName}
        onChange={(e) => setNewItemName(e.target.value)}
        placeholder="New item name"
      />
      <button onClick={handleAddItem}>Add Item</button>
    </div>
  );
};

export default ItemsComponent;

In this component:

Integrating into Your App

Finally, integrate the component into your main application

// App.js
import React from 'react';
import ItemsComponent from './ItemsComponent';

const App = () => (
  <div>
    <ItemsComponent />
  </div>
);

export default App;

Testing view model

Testing a custom hook in React, like the view model we created, involves using a testing library like @testing-library/react-hooks along with a testing framework like Jest. This allows you to isolate the logic in the hook and verify its behavior.

Here’s how you can write unit tests for the useItemsViewModel custom hook.

npm install @testing-library/react-hooks @testing-library/jest-dom

Here's how you can write tests for the custom hook:

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

// Mock fetch API
global.fetch = jest.fn();

const mockItems = [{ id: 1, name: 'Item 1' }, { id: 2, name: 'Item 2' }];

beforeEach(() => {
  fetch.mockClear();
});

test('should fetch items successfully', async () => {
  fetch.mockResolvedValueOnce({
    json: async () => mockItems,
  });

  const { result, waitForNextUpdate } = renderHook(() => useItemsViewModel());

  // Initially loading should be true
  expect(result.current.loading).toBe(true);

  // Wait for the fetch to complete
  await waitForNextUpdate();

  // Verify the hook state after fetching items
  expect(result.current.items).toEqual(mockItems);
  expect(result.current.loading).toBe(false);
  expect(result.current.error).toBe(null);
});

test('should handle fetch error', async () => {
  fetch.mockRejectedValueOnce(new Error('Failed to fetch'));

  const { result, waitForNextUpdate } = renderHook(() => useItemsViewModel());

  // Initially loading should be true
  expect(result.current.loading).toBe(true);

  // Wait for the fetch to complete
  await waitForNextUpdate();

  // Verify the hook state after the fetch error
  expect(result.current.items).toEqual([]);
  expect(result.current.loading).toBe(false);
  expect(result.current.error).toBe('Failed to fetch');
});

test('should add a new item', async () => {
  fetch.mockResolvedValueOnce({
    json: async () => mockItems,
  });

  const { result, waitForNextUpdate } = renderHook(() => useItemsViewModel());

  // Wait for the initial fetch to complete
  await waitForNextUpdate();

  const newItem = { id: 3, name: 'Item 3' };

  fetch.mockResolvedValueOnce({
    json: async () => newItem,
  });

  await act(async () => {
    await result.current.addItem(newItem);
  });

  // Verify the hook state after adding an item
  expect(result.current.items).toEqual([...mockItems, newItem]);
});

test('should remove an item', async () => {
  fetch.mockResolvedValueOnce({
    json: async () => mockItems,
  });

  const { result, waitForNextUpdate } = renderHook(() => useItemsViewModel());

  // Wait for the initial fetch to complete
  await waitForNextUpdate();

  fetch.mockResolvedValueOnce({
    json: async () => ({}),
  });

  await act(async () => {
    await result.current.removeItem(1);
  });

  // Verify the hook state after removing an item
  expect(result.current.items).toEqual(mockItems.filter(item => item.id !== 1));
});

By using a custom hook as a view model, you encapsulate all the state management and business logic within the hook. This makes your components focused on rendering the UI, leading to cleaner, more maintainable, and reusable code. The custom hook handles all the complex logic, allowing the component to remain simple and focused on what it does best—displaying the UI.