Makuhari Development Corporation
6 min read, 1110 words, last updated: 2024/9/17
TwitterLinkedInFacebookEmail

Introduction

As React applications grow in complexity, managing the separation between presentation logic and business logic becomes increasingly critical. One architectural pattern gaining traction is the adaptation of MVVM (Model-View-ViewModel) to React, where each component is split into two distinct parts: a View responsible solely for rendering, and a ViewModel implemented as a custom hook that handles all state management and business logic.

This approach promises cleaner code organization, improved testability, and better reusability. But is it always the right choice? Let's explore this pattern in depth, examining its benefits, drawbacks, and practical implementation strategies.

Background: From MVC to MVVM in React

The Evolution of Component Architecture

Traditional React components often mix presentation and business logic:

// Traditional approach - mixed concerns
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true);
      try {
        const userData = await api.getUser(userId);
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchUser();
  }, [userId]);
 
  const handleUpdate = async (updatedData) => {
    // Business logic mixed with component
    try {
      await api.updateUser(userId, updatedData);
      setUser(prev => ({ ...prev, ...updatedData }));
    } catch (err) {
      setError(err.message);
    }
  };
 
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
 
  return (
    <div>
      <h1>{user?.name}</h1>
      <UserForm user={user} onSubmit={handleUpdate} />
    </div>
  );
}

The MVVM Alternative

The MVVM pattern separates these concerns:

// ViewModel (Custom Hook)
function useUserProfileViewModel(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const fetchUser = async () => {
      setLoading(true);
      try {
        const userData = await api.getUser(userId);
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchUser();
  }, [userId]);
 
  const handleUpdate = useCallback(async (updatedData) => {
    try {
      await api.updateUser(userId, updatedData);
      setUser(prev => ({ ...prev, ...updatedData }));
    } catch (err) {
      setError(err.message);
    }
  }, [userId]);
 
  return {
    user,
    loading,
    error,
    handleUpdate
  };
}
 
// View (Pure Presentation)
function UserProfileView({ userId }) {
  const { user, loading, error, handleUpdate } = useUserProfileViewModel(userId);
 
  if (loading) return <Spinner />;
  if (error) return <ErrorMessage error={error} />;
 
  return (
    <div>
      <h1>{user?.name}</h1>
      <UserForm user={user} onSubmit={handleUpdate} />
    </div>
  );
}

Core Concepts of React MVVM

1. View Layer Responsibilities

The View layer in React MVVM should be purely presentational:

  • Render UI elements based on provided data
  • Handle user interactions by calling ViewModel methods
  • Contain no business logic or state management
  • Focus solely on the user interface
// ✅ Good View - Only presentation
function TaskListView() {
  const { tasks, loading, addTask, toggleTask, deleteTask } = useTaskViewModel();
  
  return (
    <div className="task-list">
      {loading && <LoadingSpinner />}
      <TaskForm onSubmit={addTask} />
      {tasks.map(task => (
        <TaskItem
          key={task.id}
          task={task}
          onToggle={() => toggleTask(task.id)}
          onDelete={() => deleteTask(task.id)}
        />
      ))}
    </div>
  );
}

2. ViewModel Layer Responsibilities

The ViewModel, implemented as a custom hook, handles:

  • State management
  • Business logic
  • API calls and data fetching
  • Event handlers
  • Computed values and derived state
// ✅ Comprehensive ViewModel
function useTaskViewModel() {
  const [tasks, setTasks] = useState([]);
  const [loading, setLoading] = useState(false);
  const [filter, setFilter] = useState('all');
 
  // Data fetching
  useEffect(() => {
    const fetchTasks = async () => {
      setLoading(true);
      try {
        const data = await taskService.getTasks();
        setTasks(data);
      } catch (error) {
        console.error('Failed to fetch tasks:', error);
      } finally {
        setLoading(false);
      }
    };
 
    fetchTasks();
  }, []);
 
  // Computed values
  const filteredTasks = useMemo(() => {
    switch (filter) {
      case 'completed':
        return tasks.filter(task => task.completed);
      case 'pending':
        return tasks.filter(task => !task.completed);
      default:
        return tasks;
    }
  }, [tasks, filter]);
 
  // Business logic
  const addTask = useCallback(async (taskData) => {
    try {
      const newTask = await taskService.createTask(taskData);
      setTasks(prev => [...prev, newTask]);
    } catch (error) {
      console.error('Failed to add task:', error);
    }
  }, []);
 
  const toggleTask = useCallback(async (taskId) => {
    try {
      const updatedTask = await taskService.toggleTask(taskId);
      setTasks(prev => prev.map(task => 
        task.id === taskId ? updatedTask : task
      ));
    } catch (error) {
      console.error('Failed to toggle task:', error);
    }
  }, []);
 
  const deleteTask = useCallback(async (taskId) => {
    try {
      await taskService.deleteTask(taskId);
      setTasks(prev => prev.filter(task => task.id !== taskId));
    } catch (error) {
      console.error('Failed to delete task:', error);
    }
  }, []);
 
  return {
    tasks: filteredTasks,
    loading,
    filter,
    setFilter,
    addTask,
    toggleTask,
    deleteTask
  };
}

3. Testing Strategy

One of the major advantages of this pattern is improved testability:

// Testing the ViewModel in isolation
import { renderHook, act } from '@testing-library/react';
import { useTaskViewModel } from './useTaskViewModel';
 
describe('useTaskViewModel', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
 
  it('should add a new task', async () => {
    const { result } = renderHook(() => useTaskViewModel());
    
    const newTask = { title: 'Test Task', completed: false };
    
    await act(async () => {
      await result.current.addTask(newTask);
    });
    
    expect(result.current.tasks).toHaveLength(1);
    expect(result.current.tasks[0].title).toBe('Test Task');
  });
 
  it('should toggle task completion', async () => {
    const { result } = renderHook(() => useTaskViewModel());
    
    // Setup initial task
    await act(async () => {
      await result.current.addTask({ title: 'Test', completed: false });
    });
    
    const taskId = result.current.tasks[0].id;
    
    await act(async () => {
      await result.current.toggleTask(taskId);
    });
    
    expect(result.current.tasks[0].completed).toBe(true);
  });
});

Analysis: When to Use This Pattern

✅ Ideal Scenarios

1. Complex Business Logic

// Complex form with validation, multi-step flow, and API integration
function useOrderFormViewModel() {
  const [formData, setFormData] = useState(initialOrderState);
  const [validationErrors, setValidationErrors] = useState({});
  const [currentStep, setCurrentStep] = useState(1);
  const [paymentMethods, setPaymentMethods] = useState([]);
  const [loading, setLoading] = useState(false);
 
  // Complex validation logic
  const validateStep = useCallback((step, data) => {
    // Extensive validation logic here
  }, []);
 
  // Multi-step navigation
  const nextStep = useCallback(() => {
    if (validateStep(currentStep, formData)) {
      setCurrentStep(prev => prev + 1);
    }
  }, [currentStep, formData, validateStep]);
 
  // API orchestration
  const submitOrder = useCallback(async () => {
    // Complex submission flow
  }, [formData]);
 
  return {
    formData,
    validationErrors,
    currentStep,
    paymentMethods,
    loading,
    updateFormData: setFormData,
    nextStep,
    previousStep,
    submitOrder
  };
}

2. Reusable Logic Across Multiple Views

// Same ViewModel used in different contexts
function DashboardTaskList() {
  const taskViewModel = useTaskViewModel();
  return <CompactTaskView {...taskViewModel} />;
}
 
function DetailedTaskPage() {
  const taskViewModel = useTaskViewModel();
  return <DetailedTaskView {...taskViewModel} />;
}
 
function MobileTaskApp() {
  const taskViewModel = useTaskViewModel();
  return <MobileTaskView {...taskViewModel} />;
}

❌ Scenarios to Avoid

1. Simple Presentational Components

// Overkill for simple components
function SimpleButton({ text, onClick }) {
  return <button onClick={onClick}>{text}</button>;
}
 
// No need for ViewModel here
function useSimpleButtonViewModel(text, onClick) {
  return { text, onClick }; // Unnecessary abstraction
}

2. Components with Minimal State

// Simple toggle - direct state management is cleaner
function ToggleSwitch() {
  const [isOn, setIsOn] = useState(false);
  
  return (
    <button onClick={() => setIsOn(!isOn)}>
      {isOn ? 'ON' : 'OFF'}
    </button>
  );
}
Makuhari Development Corporation
法人番号: 6040001134259
ご利用にあたって
個人情報保護方針
個人情報取扱に関する同意事項
お問い合わせ
Copyright© Makuhari Development Corporation. All Rights Reserved.