Hey folks! 👋 After spending years building React applications, I've found that as apps grow larger, maintaining a clean architecture becomes crucial. Today, I'll share how implementing MVVM (Model-View-ViewModel) pattern in React has saved our team from countless headaches and made our codebase much more manageable.
Why Should You Care? The Good Stuff First! 🎯
-
Your Code Becomes Super Organized
- Clear separation between data, logic, and UI components
- Each part of your code has one job and does it well
- No more "where should I put this logic?" moments
-
Testing Becomes a Breeze
- Business logic is isolated in ViewModels
- UI components are purely presentational
- You can test each part independently without mocking the entire universe
-
Reusability On Steroids
- ViewModels can be reused across different components
- Logic stays consistent throughout your app
- Less copy-paste, more single source of truth
-
State Management That Makes Sense
- Clear data flow throughout your application
- Predictable state updates
- Easier debugging when things go wrong (and they will!)
Let's See It In Action! 💻
Let's build a simple e-commerce product listing page with filters and sorting. Here's how we'd structure it using MVVM:
Directory Structure
src/
├── pages/
│ └── ProductsPage/
│ ├── index.tsx # Main page component
│ ├── index.hook.ts # Custom hooks
│ ├── index.store.ts # State management
│ ├── ViewModel.ts # ViewModel implementation
│ ├── types.ts # TypeScript interfaces
│ ├── components/
│ │ ├── ProductGrid/
│ │ ├── FilterPanel/
│ │ └── SortingOptions/
│ └── styles/
1. First, Define Your Model
// models/Product.model.ts
export interface Product {
id: string;
name: string;
price: number;
category: string;
inStock: boolean;
}
export interface FilterOptions {
category: string[];
minPrice: number;
maxPrice: number;
inStock: boolean;
}
2. Set Up Your Store
// pages/ProductsPage/index.store.ts
import { create } from 'zustand';
interface ProductsPageState {
products: Product[];
filters: FilterOptions;
setProducts: (products: Product[]) => void;
setFilters: (filters: FilterOptions) => void;
}
const useProductsStore = create<ProductsPageState>((set) => ({
products: [],
filters: {
category: [],
minPrice: 0,
maxPrice: 1000,
inStock: false
},
setProducts: (products) => set({ products }),
setFilters: (filters) => set({ filters })
}));
3. Create Your ViewModel
// pages/ProductsPage/ViewModel.ts
class ProductsViewModel {
private store: ProductsStore;
private uiStore: UIStore;
constructor(store: ProductsStore, uiStore: UIStore) {
this.store = store;
this.uiStore = uiStore;
}
public async fetchProducts() {
try {
this.uiStore.showLoader();
const { data } = await ProductsAPI.getProducts(this.store.filters);
this.store.setProducts(data);
} catch (error) {
toast.error('Could not fetch products');
} finally {
this.uiStore.hideLoader();
}
}
public updateFilters(filters: Partial<FilterOptions>) {
this.store.setFilters({
...this.store.filters,
...filters
});
}
public shouldShowEmptyState(): boolean {
return !this.uiStore().isLoading && this.getFilteredProducts().length === 0;
}
public shouldShowError(): boolean {
return !!this.uiStore().error;
}
public shouldShowLoading(): boolean {
return this.uiStore().isLoading;
}
public shouldShowProductDetails(): boolean {
return !!this.uiStore().selectedProductId;
}
}
4. The Custom Hook
// pages/ProductsPage/index.hook.ts
const useProductsPage = () => {
const productsStore = useProductsStore();
const uiStore = useUIStore();
const viewModel = new ProductsViewModel(productsStore, uiStore);
// isRefreshing and refreshDone are here in case you have logic that's outside the viewmodel and specific to the page itself
return {
viewModel,
isRefreshing: uiStore.isRefreshing,
refreshDone: () => uiStore.setRefreshing(false),
};
};
5. The View Component
// pages/ProductsPage/index.tsx
const ProductsPage: FC = () => {
const { viewModel, isRefreshing, refreshDone } = useProductsPage();
useEffect(() => {
viewModel.fetchProducts();
}, [viewModel]);
useEffect(() => {
if (isRefreshing) {
viewModel.fetchProducts();
refreshDone();
}
}, [isRefreshing]);
return (
<div className="products-page">
<FilterPanel />
<ProductGrid />
<SortingOptions />
</div>
);
};
Best Practices I've Learned the Hard Way 😅
- Keep ViewModels Focused
// Good
class ProductsViewModel {
fetchProducts() { /* ... */ }
updateFilters() { /* ... */ }
sortProducts() { /* ... */ }
}
// Bad - mixing concerns
class ProductsViewModel {
fetchProducts() { /* ... */ }
updateUserProfile() { /* ... */ }
handleCheckout() { /* ... */ }
}
- Handle Cleanup Properly
useEffect(() => {
const controller = new AbortController();
viewModel.fetchProducts(controller.signal);
return () => controller.abort();
}, [viewModel]);
- Don't Memoize ViewModels
// Good
const viewModel = new ProductsViewModel(store);
// Bad - will break reactivity
const viewModel = useMemo(() => new ProductsViewModel(store), [store]);
The Not-So-Great Parts (Let's Be Honest) 😕
-
More Boilerplate
- You'll write more initial code
- More files to manage
- Steeper learning curve for new team members
-
Might Be Overkill
- For simple CRUD apps, this could be excessive
- Small projects might not see the benefits
- Takes time to set up properly
-
Team Buy-in Required
- Everyone needs to understand and follow the pattern
- Requires consistent conventions
- Documentation becomes crucial
Wrapping Up 🎁
MVVM in React isn't a silver bullet, but it's been a game-changer for our team's productivity and code quality. Start small, maybe implement it in one feature first, and see how it feels. Remember, the goal is to make your code more maintainable and your life easier!
Feel free to drop any questions in the comments. Happy coding! 🚀
Top comments (0)