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>
);
}