Bab 7: Scaling Up dengan Reducer dan Context
⏱ 5 menit bacaPendahuluan: Menggabungkan Dua Kekuatan
Di bab sebelumnya, kamu udah belajar:
- Reducer (Bab 5): Mengorganisir logika state yang kompleks di satu tempat
- Context (Bab 6): Mengirim data ke komponen manapun tanpa prop drilling
Sekarang, kita gabungkan keduanya. Ini kayak menggabungkan sistem manajemen gudang (reducer) dengan jaringan distribusi (context). Gudang mengatur stok dengan rapi, jaringan distribusi memastikan semua toko cabang bisa akses stok tanpa harus lewat perantara.
Pattern ini adalah fondasi dari state management di React. Banyak library populer (Redux, Zustand) pada dasarnya menggunakan konsep yang sama. Tapi kamu gak perlu library tambahan... React sendiri sudah menyediakan semua yang dibutuhkan.
Kenapa Gabungkan Reducer + Context?
Masalah dengan Reducer Saja
// Reducer di parent, tapi child butuh dispatch...
function App() {
const [state, dispatch] = useReducer(todoReducer, initialState);
// Harus passing dispatch ke bawah lewat props 😩
return (
<Layout dispatch={dispatch} state={state}>
<Sidebar dispatch={dispatch} todos={state.todos} />
<Main dispatch={dispatch} todos={state.todos} filter={state.filter} />
</Layout>
);
}Reducer mengorganisir logika, tapi kamu masih harus prop-drill dispatch dan state ke semua komponen yang butuh.
Masalah dengan Context Saja
// Context tanpa reducer = logika tersebar di mana-mana
function TodoProvider({ children }) {
const [todos, setTodos] = useState([]);
// Semua logika update di sini... makin lama makin panjang
function tambah(teks) { setTodos([...todos, { id: Date.now(), teks, selesai: false }]); }
function hapus(id) { setTodos(todos.filter(t => t.id !== id)); }
function toggle(id) { setTodos(todos.map(t => t.id === id ? { ...t, selesai: !t.selesai } : t)); }
function edit(id, teks) { setTodos(todos.map(t => t.id === id ? { ...t, teks } : t)); }
function hapusSemua() { setTodos([]); }
function tandaiSemua() { setTodos(todos.map(t => ({ ...t, selesai: true }))); }
// ... 10 fungsi lagi
return (
<TodoContext.Provider value={{ todos, tambah, hapus, toggle, edit, hapusSemua, tandaiSemua }}>
{children}
</TodoContext.Provider>
);
}Context menghilangkan prop drilling, tapi logika update jadi berantakan di dalam Provider.
Solusi: Gabungkan Keduanya!
- Reducer → mengorganisir SEMUA logika update di satu tempat
- Context → mendistribusikan state dan dispatch ke SEMUA komponen
┌─────────────────────────────────────────┐
│ Provider (gabungan Context + Reducer) │
│ │
│ useReducer(reducer, initialState) │
│ ↓ ↓ │
│ [state] [dispatch] │
│ ↓ ↓ │
│ StateContext DispatchContext │
│ ↓ ↓ │
│ ┌─────────────────────────┐ │
│ │ Semua Child Components │ │
│ │ bisa akses langsung! │ │
│ └─────────────────────────┘ │
└─────────────────────────────────────────┘
Langkah demi Langkah: Membangun Pattern Ini
Langkah 1: Buat Reducer
// todoReducer.js
export const initialState = {
todos: [],
filter: 'semua', // 'semua' | 'aktif' | 'selesai'
nextId: 1,
};
export function todoReducer(state, action) {
switch (action.type) {
case 'tambah':
return {
...state,
todos: [
...state.todos,
{ id: state.nextId, teks: action.teks, selesai: false },
],
nextId: state.nextId + 1,
};
case 'toggle':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, selesai: !t.selesai } : t
),
};
case 'hapus':
return {
...state,
todos: state.todos.filter(t => t.id !== action.id),
};
case 'edit':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, teks: action.teks } : t
),
};
case 'set_filter':
return { ...state, filter: action.filter };
case 'tandai_semua_selesai':
return {
...state,
todos: state.todos.map(t => ({ ...t, selesai: true })),
};
case 'hapus_yang_selesai':
return {
...state,
todos: state.todos.filter(t => !t.selesai),
};
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}Langkah 2: Buat Context (Pisahkan State dan Dispatch)
// TodoContext.js
import { createContext } from 'react';
// Kenapa dipisah? Supaya komponen yang CUMA butuh dispatch
// gak re-render saat state berubah!
export const TodoStateContext = createContext(null);
export const TodoDispatchContext = createContext(null);Kenapa pisah State dan Dispatch?
Bayangin di kantor:
- Papan pengumuman (StateContext): Semua orang bisa BACA
- Kotak saran (DispatchContext): Semua orang bisa KIRIM saran
Kalau pengumuman berubah, yang perlu update cuma orang yang BACA papan. Orang yang cuma kirim saran gak perlu tau pengumuman berubah.
Sama di React: komponen yang cuma dispatch (misal tombol "Hapus") gak perlu re-render saat state berubah.
Langkah 3: Buat Provider Component
// TodoProvider.jsx
import { useReducer } from 'react';
import { TodoStateContext, TodoDispatchContext } from './TodoContext';
import { todoReducer, initialState } from './todoReducer';
export function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}Langkah 4: Buat Custom Hooks
// useTodo.js
import { useContext } from 'react';
import { TodoStateContext, TodoDispatchContext } from './TodoContext';
// Hook untuk baca state
export function useTodoState() {
const context = useContext(TodoStateContext);
if (context === null) {
throw new Error('useTodoState harus digunakan di dalam TodoProvider');
}
return context;
}
// Hook untuk dispatch actions
export function useTodoDispatch() {
const context = useContext(TodoDispatchContext);
if (context === null) {
throw new Error('useTodoDispatch harus digunakan di dalam TodoProvider');
}
return context;
}
// Hook gabungan (kalau butuh keduanya)
export function useTodo() {
return {
state: useTodoState(),
dispatch: useTodoDispatch(),
};
}Langkah 5: Pakai di Komponen!
// App.jsx
import { TodoProvider } from './TodoProvider';
import { FormTambah } from './FormTambah';
import { DaftarTodo } from './DaftarTodo';
import { FilterBar } from './FilterBar';
import { StatusBar } from './StatusBar';
function App() {
return (
<TodoProvider>
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1>📋 Todo App</h1>
<FormTambah />
<FilterBar />
<DaftarTodo />
<StatusBar />
</div>
</TodoProvider>
);
}Contoh Lengkap: Todo App dengan Reducer + Context
Ini aplikasi todo yang lengkap menggunakan pattern Reducer + Context:
import { createContext, useContext, useReducer, useState } from 'react';
// ==================== REDUCER ====================
const initialState = {
todos: [
{ id: 1, teks: 'Belajar React', selesai: true },
{ id: 2, teks: 'Bikin project', selesai: false },
{ id: 3, teks: 'Deploy ke production', selesai: false },
],
filter: 'semua',
nextId: 4,
};
function todoReducer(state, action) {
switch (action.type) {
case 'tambah':
return {
...state,
todos: [...state.todos, { id: state.nextId, teks: action.teks, selesai: false }],
nextId: state.nextId + 1,
};
case 'toggle':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, selesai: !t.selesai } : t
),
};
case 'hapus':
return {
...state,
todos: state.todos.filter(t => t.id !== action.id),
};
case 'edit':
return {
...state,
todos: state.todos.map(t =>
t.id === action.id ? { ...t, teks: action.teks } : t
),
};
case 'set_filter':
return { ...state, filter: action.filter };
case 'tandai_semua':
return {
...state,
todos: state.todos.map(t => ({ ...t, selesai: true })),
};
case 'hapus_selesai':
return {
...state,
todos: state.todos.filter(t => !t.selesai),
};
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
// ==================== CONTEXT ====================
const TodoStateContext = createContext(null);
const TodoDispatchContext = createContext(null);
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(todoReducer, initialState);
return (
<TodoStateContext.Provider value={state}>
<TodoDispatchContext.Provider value={dispatch}>
{children}
</TodoDispatchContext.Provider>
</TodoStateContext.Provider>
);
}
// Custom hooks
function useTodoState() {
const ctx = useContext(TodoStateContext);
if (!ctx) throw new Error('useTodoState harus di dalam TodoProvider');
return ctx;
}
function useTodoDispatch() {
const ctx = useContext(TodoDispatchContext);
if (!ctx) throw new Error('useTodoDispatch harus di dalam TodoProvider');
return ctx;
}
// ==================== KOMPONEN UI ====================
// Form untuk tambah todo baru
function FormTambah() {
const [teks, setTeks] = useState('');
const dispatch = useTodoDispatch(); // Cuma butuh dispatch, gak butuh state
function handleSubmit(e) {
e.preventDefault();
if (!teks.trim()) return;
dispatch({ type: 'tambah', teks: teks.trim() });
setTeks('');
}
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
<input
value={teks}
onChange={(e) => setTeks(e.target.value)}
placeholder="Apa yang mau dikerjain?"
style={{ flex: 1, padding: '10px', fontSize: '16px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
<button type="submit" style={{ padding: '10px 20px', background: '#4CAF50', color: 'white', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
+ Tambah
</button>
</form>
);
}
// Bar filter
function FilterBar() {
const { filter } = useTodoState();
const dispatch = useTodoDispatch();
const opsi = [
{ value: 'semua', label: 'Semua' },
{ value: 'aktif', label: 'Aktif' },
{ value: 'selesai', label: 'Selesai' },
];
return (
<div style={{ display: 'flex', gap: '8px', marginBottom: '15px' }}>
{opsi.map(o => (
<button
key={o.value}
onClick={() => dispatch({ type: 'set_filter', filter: o.value })}
style={{
padding: '6px 12px',
background: filter === o.value ? '#2196F3' : '#eee',
color: filter === o.value ? 'white' : '#333',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
}}
>
{o.label}
</button>
))}
<button
onClick={() => dispatch({ type: 'tandai_semua' })}
style={{ marginLeft: 'auto', padding: '6px 12px', background: '#eee', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
✓ Tandai Semua
</button>
<button
onClick={() => dispatch({ type: 'hapus_selesai' })}
style={{ padding: '6px 12px', background: '#ffebee', color: '#c62828', border: 'none', borderRadius: '4px', cursor: 'pointer' }}
>
🗑️ Hapus Selesai
</button>
</div>
);
}
// Daftar todo
function DaftarTodo() {
const { todos, filter } = useTodoState();
// Filter todos berdasarkan filter aktif
const todosTerfilter = todos.filter(t => {
if (filter === 'aktif') return !t.selesai;
if (filter === 'selesai') return t.selesai;
return true;
});
if (todosTerfilter.length === 0) {
return (
<p style={{ textAlign: 'center', color: '#999', padding: '20px' }}>
{filter === 'semua' ? 'Belum ada todo. Tambah yang pertama!' : `Tidak ada todo yang ${filter}.`}
</p>
);
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todosTerfilter.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
// Item todo individual
function TodoItem({ todo }) {
const dispatch = useTodoDispatch();
const [sedangEdit, setSedangEdit] = useState(false);
const [draft, setDraft] = useState(todo.teks);
function handleSimpan() {
if (draft.trim()) {
dispatch({ type: 'edit', id: todo.id, teks: draft.trim() });
}
setSedangEdit(false);
}
if (sedangEdit) {
return (
<li style={{ padding: '10px', marginBottom: '8px', background: '#fff3e0', borderRadius: '4px' }}>
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSimpan();
if (e.key === 'Escape') setSedangEdit(false);
}}
autoFocus
style={{ width: '70%', padding: '5px' }}
/>
<button onClick={handleSimpan} style={{ marginLeft: '8px' }}>💾</button>
<button onClick={() => setSedangEdit(false)}>❌</button>
</li>
);
}
return (
<li style={{
padding: '10px',
marginBottom: '8px',
background: todo.selesai ? '#f5f5f5' : 'white',
border: '1px solid #eee',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
gap: '10px',
}}>
<input
type="checkbox"
checked={todo.selesai}
onChange={() => dispatch({ type: 'toggle', id: todo.id })}
style={{ width: '18px', height: '18px' }}
/>
<span style={{
flex: 1,
textDecoration: todo.selesai ? 'line-through' : 'none',
color: todo.selesai ? '#999' : '#333',
}}>
{todo.teks}
</span>
<button
onClick={() => setSedangEdit(true)}
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
✏️
</button>
<button
onClick={() => dispatch({ type: 'hapus', id: todo.id })}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'red' }}
>
🗑️
</button>
</li>
);
}
// Status bar
function StatusBar() {
const { todos } = useTodoState();
const total = todos.length;
const selesai = todos.filter(t => t.selesai).length;
const aktif = total - selesai;
const persen = total > 0 ? Math.round((selesai / total) * 100) : 0;
return (
<div style={{ marginTop: '20px', padding: '15px', background: '#f5f5f5', borderRadius: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span>📊 {selesai}/{total} selesai ({persen}%)</span>
<span>{aktif} tersisa</span>
</div>
<div style={{ background: '#ddd', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{
width: `${persen}%`,
height: '8px',
background: persen === 100 ? '#4CAF50' : '#2196F3',
transition: 'width 0.3s ease',
}} />
</div>
</div>
);
}
// ==================== APP ====================
function App() {
return (
<TodoProvider>
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px', fontFamily: 'sans-serif' }}>
<h1 style={{ textAlign: 'center' }}>📋 Todo App</h1>
<FormTambah />
<FilterBar />
<DaftarTodo />
<StatusBar />
</div>
</TodoProvider>
);
}Memisahkan State Logic dari UI
Di restoran besar:
- Dapur (reducer): Semua logika memasak ada di sini
- Sistem pesanan (context): Menghubungkan pelayan dengan dapur
- Pelayan (komponen UI): Cuma terima pesanan dan antar makanan
Pelayan gak perlu tau cara masak. Dapur gak perlu tau meja mana yang pesan. Sistem pesanan yang menghubungkan semuanya.
Struktur File yang Direkomendasikan
src/
├── App.jsx # Entry point
├── features/
│ └── todo/
│ ├── todoReducer.js # Reducer + initial state
│ ├── TodoContext.js # Context definitions
│ ├── TodoProvider.jsx # Provider component
│ ├── useTodo.js # Custom hooks
│ └── components/
│ ├── FormTambah.jsx
│ ├── DaftarTodo.jsx
│ ├── TodoItem.jsx
│ ├── FilterBar.jsx
│ └── StatusBar.jsx
Keuntungan Pemisahan Ini
- Testable: Reducer bisa di-test tanpa render komponen
- Reusable: Provider bisa dipakai di mana aja
- Maintainable: Mau tambah fitur? Tambah case di reducer, gak perlu ubah UI
- Debuggable: Semua perubahan state bisa dilacak lewat action types
Testing Reducer (Bonus)
Salah satu keuntungan terbesar reducer: mudah di-test!
// todoReducer.test.js
import { todoReducer, initialState } from './todoReducer';
// Test tambah todo
test('tambah todo baru', () => {
const state = todoReducer(initialState, {
type: 'tambah',
teks: 'Belajar testing',
});
expect(state.todos).toHaveLength(initialState.todos.length + 1);
expect(state.todos[state.todos.length - 1].teks).toBe('Belajar testing');
expect(state.todos[state.todos.length - 1].selesai).toBe(false);
});
// Test toggle todo
test('toggle todo', () => {
const stateAwal = {
...initialState,
todos: [{ id: 1, teks: 'Test', selesai: false }],
};
const state = todoReducer(stateAwal, { type: 'toggle', id: 1 });
expect(state.todos[0].selesai).toBe(true);
// Toggle lagi
const state2 = todoReducer(state, { type: 'toggle', id: 1 });
expect(state2.todos[0].selesai).toBe(false);
});
// Test hapus todo
test('hapus todo', () => {
const stateAwal = {
...initialState,
todos: [
{ id: 1, teks: 'A', selesai: false },
{ id: 2, teks: 'B', selesai: false },
],
};
const state = todoReducer(stateAwal, { type: 'hapus', id: 1 });
expect(state.todos).toHaveLength(1);
expect(state.todos[0].id).toBe(2);
});
// Test action yang gak dikenal
test('throw error untuk action yang gak dikenal', () => {
expect(() => {
todoReducer(initialState, { type: 'action_ngaco' });
}).toThrow('Action tidak dikenal: action_ngaco');
});Lihat betapa mudahnya! Reducer itu pure function: kasih input (state + action), cek output (state baru). Gak perlu render komponen, gak perlu simulasi klik.
Pattern Lanjutan: Action Creators
Kalau action objects mulai repetitif, kamu bisa bikin "action creators" (fungsi yang bikin action object):
// todoActions.js
// Action creators: fungsi yang return action object
export const todoActions = {
tambah: (teks) => ({ type: 'tambah', teks }),
toggle: (id) => ({ type: 'toggle', id }),
hapus: (id) => ({ type: 'hapus', id }),
edit: (id, teks) => ({ type: 'edit', id, teks }),
setFilter: (filter) => ({ type: 'set_filter', filter }),
tandaiSemua: () => ({ type: 'tandai_semua' }),
hapusSelesai: () => ({ type: 'hapus_selesai' }),
};// Penggunaan di komponen
import { todoActions } from './todoActions';
function FormTambah() {
const dispatch = useTodoDispatch();
const [teks, setTeks] = useState('');
function handleSubmit(e) {
e.preventDefault();
// Pakai action creator, lebih bersih!
dispatch(todoActions.tambah(teks));
setTeks('');
}
// ...
}
function TodoItem({ todo }) {
const dispatch = useTodoDispatch();
return (
<li>
<input
type="checkbox"
checked={todo.selesai}
onChange={() => dispatch(todoActions.toggle(todo.id))}
/>
<span>{todo.teks}</span>
<button onClick={() => dispatch(todoActions.hapus(todo.id))}>🗑️</button>
</li>
);
}Keuntungan action creators:
- Autocomplete di IDE (tinggal ketik
todoActions.dan lihat semua opsi) - Typo langsung ketahuan (kalau salah nama fungsi, error)
- Satu tempat untuk lihat semua action yang tersedia
Contoh Dunia Nyata: Aplikasi E-Commerce Mini
import { createContext, useContext, useReducer, useState } from 'react';
// ==================== REDUCER ====================
const initialState = {
produk: [
{ id: 'p1', nama: 'Kaos React', harga: 120000, stok: 10 },
{ id: 'p2', nama: 'Hoodie JS', harga: 250000, stok: 5 },
{ id: 'p3', nama: 'Sticker Pack', harga: 25000, stok: 50 },
{ id: 'p4', nama: 'Mug Programmer', harga: 75000, stok: 20 },
],
keranjang: [],
checkout: null, // null | 'proses' | 'sukses' | 'gagal'
};
function tokoReducer(state, action) {
switch (action.type) {
case 'tambah_ke_keranjang': {
const produk = state.produk.find(p => p.id === action.produkId);
if (!produk || produk.stok <= 0) return state;
const existing = state.keranjang.find(i => i.produkId === action.produkId);
if (existing) {
if (existing.jumlah >= produk.stok) return state; // Stok gak cukup
return {
...state,
keranjang: state.keranjang.map(i =>
i.produkId === action.produkId
? { ...i, jumlah: i.jumlah + 1 }
: i
),
};
}
return {
...state,
keranjang: [...state.keranjang, { produkId: action.produkId, jumlah: 1 }],
};
}
case 'kurangi_dari_keranjang': {
const item = state.keranjang.find(i => i.produkId === action.produkId);
if (!item) return state;
if (item.jumlah === 1) {
return {
...state,
keranjang: state.keranjang.filter(i => i.produkId !== action.produkId),
};
}
return {
...state,
keranjang: state.keranjang.map(i =>
i.produkId === action.produkId
? { ...i, jumlah: i.jumlah - 1 }
: i
),
};
}
case 'hapus_dari_keranjang':
return {
...state,
keranjang: state.keranjang.filter(i => i.produkId !== action.produkId),
};
case 'mulai_checkout':
return { ...state, checkout: 'proses' };
case 'checkout_sukses':
return {
...state,
checkout: 'sukses',
keranjang: [],
// Kurangi stok
produk: state.produk.map(p => {
const itemDiKeranjang = state.keranjang.find(i => i.produkId === p.id);
if (itemDiKeranjang) {
return { ...p, stok: p.stok - itemDiKeranjang.jumlah };
}
return p;
}),
};
case 'checkout_gagal':
return { ...state, checkout: 'gagal' };
case 'reset_checkout':
return { ...state, checkout: null };
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
// ==================== CONTEXT + PROVIDER ====================
const TokoStateContext = createContext(null);
const TokoDispatchContext = createContext(null);
function TokoProvider({ children }) {
const [state, dispatch] = useReducer(tokoReducer, initialState);
return (
<TokoStateContext.Provider value={state}>
<TokoDispatchContext.Provider value={dispatch}>
{children}
</TokoDispatchContext.Provider>
</TokoStateContext.Provider>
);
}
function useTokoState() {
const ctx = useContext(TokoStateContext);
if (!ctx) throw new Error('useTokoState harus di dalam TokoProvider');
return ctx;
}
function useTokoDispatch() {
const ctx = useContext(TokoDispatchContext);
if (!ctx) throw new Error('useTokoDispatch harus di dalam TokoProvider');
return ctx;
}
// ==================== KOMPONEN ====================
function App() {
return (
<TokoProvider>
<div style={{ fontFamily: 'sans-serif', maxWidth: '800px', margin: '0 auto' }}>
<HeaderToko />
<div style={{ display: 'flex', gap: '20px', padding: '20px' }}>
<div style={{ flex: 2 }}>
<KatalogProduk />
</div>
<div style={{ flex: 1 }}>
<PanelKeranjang />
</div>
</div>
</div>
</TokoProvider>
);
}
function HeaderToko() {
const { keranjang } = useTokoState();
const totalItem = keranjang.reduce((sum, i) => sum + i.jumlah, 0);
return (
<header style={{ background: '#1a237e', color: 'white', padding: '15px 20px', display: 'flex', justifyContent: 'space-between' }}>
<h1 style={{ margin: 0 }}>🛍️ DevStore</h1>
<span style={{ fontSize: '20px' }}>
🛒 {totalItem > 0 && (
<span style={{ background: '#ff5722', borderRadius: '50%', padding: '2px 8px', fontSize: '14px' }}>
{totalItem}
</span>
)}
</span>
</header>
);
}
function KatalogProduk() {
const { produk } = useTokoState();
const dispatch = useTokoDispatch();
return (
<div>
<h2>📦 Produk</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '15px' }}>
{produk.map(p => (
<div key={p.id} style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '15px',
opacity: p.stok === 0 ? 0.5 : 1,
}}>
<h3 style={{ margin: '0 0 5px' }}>{p.nama}</h3>
<p style={{ color: '#1a237e', fontWeight: 'bold' }}>
Rp {p.harga.toLocaleString()}
</p>
<p style={{ color: '#666', fontSize: '14px' }}>Stok: {p.stok}</p>
<button
onClick={() => dispatch({ type: 'tambah_ke_keranjang', produkId: p.id })}
disabled={p.stok === 0}
style={{
width: '100%',
padding: '8px',
background: p.stok === 0 ? '#ccc' : '#4CAF50',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: p.stok === 0 ? 'not-allowed' : 'pointer',
}}
>
{p.stok === 0 ? 'Habis' : '+ Keranjang'}
</button>
</div>
))}
</div>
</div>
);
}
function PanelKeranjang() {
const { keranjang, produk, checkout } = useTokoState();
const dispatch = useTokoDispatch();
// Derived: detail keranjang
const detailKeranjang = keranjang.map(item => {
const p = produk.find(pr => pr.id === item.produkId);
return { ...p, jumlah: item.jumlah, subtotal: p.harga * item.jumlah };
});
const totalHarga = detailKeranjang.reduce((sum, i) => sum + i.subtotal, 0);
// Handle checkout
async function handleCheckout() {
dispatch({ type: 'mulai_checkout' });
// Simulasi API call
await new Promise(resolve => setTimeout(resolve, 2000));
// 90% chance sukses
if (Math.random() > 0.1) {
dispatch({ type: 'checkout_sukses' });
} else {
dispatch({ type: 'checkout_gagal' });
}
}
if (checkout === 'sukses') {
return (
<div style={{ padding: '20px', background: '#e8f5e9', borderRadius: '8px', textAlign: 'center' }}>
<h3>🎉 Pesanan Berhasil!</h3>
<p>Terima kasih sudah belanja di DevStore.</p>
<button onClick={() => dispatch({ type: 'reset_checkout' })}>
Belanja Lagi
</button>
</div>
);
}
return (
<div style={{ background: '#fafafa', padding: '15px', borderRadius: '8px', position: 'sticky', top: '20px' }}>
<h2>🛒 Keranjang</h2>
{keranjang.length === 0 ? (
<p style={{ color: '#999' }}>Keranjang kosong</p>
) : (
<>
{detailKeranjang.map(item => (
<div key={item.id} style={{ marginBottom: '10px', paddingBottom: '10px', borderBottom: '1px solid #eee' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<strong>{item.nama}</strong>
<button
onClick={() => dispatch({ type: 'hapus_dari_keranjang', produkId: item.id })}
style={{ background: 'none', border: 'none', color: 'red', cursor: 'pointer' }}
>
✕
</button>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginTop: '5px' }}>
<button onClick={() => dispatch({ type: 'kurangi_dari_keranjang', produkId: item.id })}>-</button>
<span>{item.jumlah}</span>
<button onClick={() => dispatch({ type: 'tambah_ke_keranjang', produkId: item.id })}>+</button>
<span style={{ marginLeft: 'auto' }}>Rp {item.subtotal.toLocaleString()}</span>
</div>
</div>
))}
<div style={{ borderTop: '2px solid #333', paddingTop: '10px', marginTop: '10px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
<span>Total:</span>
<span>Rp {totalHarga.toLocaleString()}</span>
</div>
</div>
<button
onClick={handleCheckout}
disabled={checkout === 'proses'}
style={{
width: '100%',
marginTop: '15px',
padding: '12px',
background: checkout === 'proses' ? '#ccc' : '#1a237e',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: checkout === 'proses' ? 'not-allowed' : 'pointer',
fontSize: '16px',
}}
>
{checkout === 'proses' ? '⏳ Memproses...' : '💳 Checkout'}
</button>
{checkout === 'gagal' && (
<p style={{ color: 'red', marginTop: '10px' }}>
❌ Checkout gagal. Silakan coba lagi.
</p>
)}
</>
)}
</div>
);
}Kapan Pakai Pattern Ini vs Library Eksternal?
| Situasi | Rekomendasi |
|---|---|
| Aplikasi kecil-menengah (1-20 komponen yang share state) | ✅ Reducer + Context cukup |
| State yang jarang berubah (tema, auth, bahasa) | ✅ Reducer + Context cocok |
| Aplikasi besar dengan banyak developer | Pertimbangkan Redux/Zustand |
| Perlu middleware (logging, async) | Pertimbangkan Redux/Zustand |
| Perlu devtools yang canggih | Pertimbangkan Redux |
| Mau yang simpel tapi powerful | Zustand atau Jotai |
Untuk kebanyakan aplikasi React, pattern Reducer + Context sudah LEBIH dari cukup. Jangan buru-buru pakai library eksternal kalau belum benar-benar butuh.
⚠️ Jebakan
Jebakan 1: Gak Pisah State dan Dispatch Context
// ❌ Satu context untuk semuanya
const TodoContext = createContext(null);
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<TodoContext.Provider value={{ state, dispatch }}>
{children}
</TodoContext.Provider>
);
}
// Masalah: komponen yang CUMA dispatch (misal tombol hapus)
// tetap re-render saat state berubah!
// ✅ Pisahkan
const StateContext = createContext(null);
const DispatchContext = createContext(null);
function TodoProvider({ children }) {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
}Jebakan 2: Menaruh Async Logic di Reducer
// ❌ JANGAN! Reducer harus pure (tanpa side effect)
function reducer(state, action) {
switch (action.type) {
case 'fetch_data':
// SALAH: fetch adalah side effect!
fetch('/api/data').then(res => res.json()).then(data => {
// Gimana caranya return state baru dari sini? GAK BISA!
});
return state;
}
}
// ✅ Async logic di event handler atau custom hook
function useTodoActions() {
const dispatch = useTodoDispatch();
async function fetchTodos() {
dispatch({ type: 'fetch_mulai' });
try {
const res = await fetch('/api/todos');
const data = await res.json();
dispatch({ type: 'fetch_sukses', data });
} catch (err) {
dispatch({ type: 'fetch_gagal', error: err.message });
}
}
return { fetchTodos };
}Jebakan 3: Provider Terlalu Banyak Nested
// ❌ "Provider Hell"
function App() {
return (
<AuthProvider>
<TemaProvider>
<BahasaProvider>
<NotifikasiProvider>
<KeranjangProvider>
<NavigasiProvider>
<ModalProvider>
<Content /> {/* Terkubur 7 level! */}
</ModalProvider>
</NavigasiProvider>
</KeranjangProvider>
</NotifikasiProvider>
</BahasaProvider>
</TemaProvider>
</AuthProvider>
);
}
// ✅ Bikin helper component
function AppProviders({ children }) {
return (
<AuthProvider>
<TemaProvider>
<BahasaProvider>
<NotifikasiProvider>
<KeranjangProvider>
{children}
</KeranjangProvider>
</NotifikasiProvider>
</BahasaProvider>
</TemaProvider>
</AuthProvider>
);
}
function App() {
return (
<AppProviders>
<Content />
</AppProviders>
);
}Jebakan 4: Lupa Error Handling di Custom Hook
// ❌ Kalau lupa bungkus Provider, error-nya gak jelas
function useTodoState() {
return useContext(TodoStateContext); // Bisa return null tanpa warning!
}
// ✅ Kasih error message yang jelas
function useTodoState() {
const context = useContext(TodoStateContext);
if (context === null) {
throw new Error(
'useTodoState() dipanggil di luar TodoProvider. ' +
'Pastikan komponen ini ada di dalam <TodoProvider>.'
);
}
return context;
}🏋️ Challenge
Challenge 1: Expense Tracker
Bikin aplikasi pencatat pengeluaran dengan Reducer + Context:
- Tambah pengeluaran (nama, jumlah, kategori)
- Hapus pengeluaran
- Filter berdasarkan kategori
- Tampilkan total per kategori dan grand total
Hint: State: { pengeluaran: [], filter: 'semua' }. Pisahkan StateContext dan DispatchContext.
Lihat Solusi
import { createContext, useContext, useReducer, useState } from 'react';
// Reducer
const initialState = {
pengeluaran: [
{ id: 1, nama: 'Makan siang', jumlah: 35000, kategori: 'makanan' },
{ id: 2, nama: 'Grab ke kantor', jumlah: 25000, kategori: 'transport' },
{ id: 3, nama: 'Netflix', jumlah: 54000, kategori: 'hiburan' },
],
filter: 'semua',
nextId: 4,
};
function expenseReducer(state, action) {
switch (action.type) {
case 'tambah':
return {
...state,
pengeluaran: [...state.pengeluaran, {
id: state.nextId,
nama: action.nama,
jumlah: action.jumlah,
kategori: action.kategori,
}],
nextId: state.nextId + 1,
};
case 'hapus':
return {
...state,
pengeluaran: state.pengeluaran.filter(p => p.id !== action.id),
};
case 'set_filter':
return { ...state, filter: action.filter };
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
// Context
const ExpenseStateCtx = createContext(null);
const ExpenseDispatchCtx = createContext(null);
function ExpenseProvider({ children }) {
const [state, dispatch] = useReducer(expenseReducer, initialState);
return (
<ExpenseStateCtx.Provider value={state}>
<ExpenseDispatchCtx.Provider value={dispatch}>
{children}
</ExpenseDispatchCtx.Provider>
</ExpenseStateCtx.Provider>
);
}
function useExpenseState() {
const ctx = useContext(ExpenseStateCtx);
if (!ctx) throw new Error('Harus di dalam ExpenseProvider');
return ctx;
}
function useExpenseDispatch() {
const ctx = useContext(ExpenseDispatchCtx);
if (!ctx) throw new Error('Harus di dalam ExpenseProvider');
return ctx;
}
// Komponen
const KATEGORI = ['makanan', 'transport', 'hiburan', 'belanja', 'lainnya'];
function App() {
return (
<ExpenseProvider>
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '20px' }}>
<h1>💰 Expense Tracker</h1>
<FormTambah />
<Ringkasan />
<FilterKategori />
<DaftarPengeluaran />
</div>
</ExpenseProvider>
);
}
function FormTambah() {
const dispatch = useExpenseDispatch();
const [nama, setNama] = useState('');
const [jumlah, setJumlah] = useState('');
const [kategori, setKategori] = useState('makanan');
function handleSubmit(e) {
e.preventDefault();
if (!nama.trim() || !jumlah) return;
dispatch({ type: 'tambah', nama, jumlah: Number(jumlah), kategori });
setNama('');
setJumlah('');
}
return (
<form onSubmit={handleSubmit} style={{ background: '#f5f5f5', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<input placeholder="Nama pengeluaran" value={nama} onChange={(e) => setNama(e.target.value)} style={{ flex: 2, padding: '8px' }} />
<input type="number" placeholder="Jumlah (Rp)" value={jumlah} onChange={(e) => setJumlah(e.target.value)} style={{ flex: 1, padding: '8px' }} />
<select value={kategori} onChange={(e) => setKategori(e.target.value)} style={{ padding: '8px' }}>
{KATEGORI.map(k => <option key={k} value={k}>{k}</option>)}
</select>
<button type="submit" style={{ padding: '8px 16px' }}>+ Tambah</button>
</div>
</form>
);
}
function Ringkasan() {
const { pengeluaran } = useExpenseState();
const total = pengeluaran.reduce((sum, p) => sum + p.jumlah, 0);
const perKategori = KATEGORI.map(k => ({
kategori: k,
total: pengeluaran.filter(p => p.kategori === k).reduce((sum, p) => sum + p.jumlah, 0),
})).filter(k => k.total > 0);
return (
<div style={{ background: '#e3f2fd', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<h3 style={{ margin: '0 0 10px' }}>Total: Rp {total.toLocaleString()}</h3>
<div style={{ display: 'flex', gap: '15px', flexWrap: 'wrap' }}>
{perKategori.map(k => (
<span key={k.kategori} style={{ fontSize: '14px' }}>
{k.kategori}: Rp {k.total.toLocaleString()}
</span>
))}
</div>
</div>
);
}
function FilterKategori() {
const { filter } = useExpenseState();
const dispatch = useExpenseDispatch();
return (
<div style={{ marginBottom: '15px', display: 'flex', gap: '5px', flexWrap: 'wrap' }}>
<button onClick={() => dispatch({ type: 'set_filter', filter: 'semua' })} style={{ padding: '5px 10px', background: filter === 'semua' ? '#2196F3' : '#eee', color: filter === 'semua' ? 'white' : 'black', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Semua
</button>
{KATEGORI.map(k => (
<button key={k} onClick={() => dispatch({ type: 'set_filter', filter: k })} style={{ padding: '5px 10px', background: filter === k ? '#2196F3' : '#eee', color: filter === k ? 'white' : 'black', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
{k}
</button>
))}
</div>
);
}
function DaftarPengeluaran() {
const { pengeluaran, filter } = useExpenseState();
const dispatch = useExpenseDispatch();
const terfilter = filter === 'semua'
? pengeluaran
: pengeluaran.filter(p => p.kategori === filter);
return (
<div>
{terfilter.map(p => (
<div key={p.id} style={{ display: 'flex', alignItems: 'center', padding: '10px', borderBottom: '1px solid #eee' }}>
<div style={{ flex: 1 }}>
<strong>{p.nama}</strong>
<span style={{ marginLeft: '10px', fontSize: '12px', background: '#eee', padding: '2px 6px', borderRadius: '4px' }}>{p.kategori}</span>
</div>
<span style={{ fontWeight: 'bold', marginRight: '10px' }}>Rp {p.jumlah.toLocaleString()}</span>
<button onClick={() => dispatch({ type: 'hapus', id: p.id })} style={{ color: 'red', background: 'none', border: 'none', cursor: 'pointer' }}>🗑️</button>
</div>
))}
</div>
);
}Challenge 2: Multi-User Chat Room
Bikin chat room sederhana dimana:
- Ada 2 "user" (simulasi, bisa switch antar user)
- Pesan yang dikirim muncul di chat room bersama
- Setiap user punya warna berbeda
- Ada indikator "sedang mengetik"
Pakai Reducer + Context untuk manage state chat.
Hint: State: { pesan: [], userAktif: 'user1', sedangMengetik: null }.
Lihat Solusi
import { createContext, useContext, useReducer, useState, useEffect } from 'react';
// Data user
const USERS = {
user1: { id: 'user1', nama: 'Andi', warna: '#1976d2', avatar: '👨' },
user2: { id: 'user2', nama: 'Budi', warna: '#388e3c', avatar: '👦' },
};
// Reducer
const initialState = {
pesan: [
{ id: 1, userId: 'user1', teks: 'Halo Budi!', waktu: '10:00' },
{ id: 2, userId: 'user2', teks: 'Hey Andi, apa kabar?', waktu: '10:01' },
],
userAktif: 'user1',
sedangMengetik: null, // null atau userId
nextId: 3,
};
function chatReducer(state, action) {
switch (action.type) {
case 'kirim_pesan':
return {
...state,
pesan: [...state.pesan, {
id: state.nextId,
userId: state.userAktif,
teks: action.teks,
waktu: new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }),
}],
nextId: state.nextId + 1,
sedangMengetik: null,
};
case 'ganti_user':
return { ...state, userAktif: action.userId, sedangMengetik: null };
case 'mulai_mengetik':
return { ...state, sedangMengetik: state.userAktif };
case 'berhenti_mengetik':
return { ...state, sedangMengetik: null };
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
// Context
const ChatStateCtx = createContext(null);
const ChatDispatchCtx = createContext(null);
function ChatProvider({ children }) {
const [state, dispatch] = useReducer(chatReducer, initialState);
return (
<ChatStateCtx.Provider value={state}>
<ChatDispatchCtx.Provider value={dispatch}>
{children}
</ChatDispatchCtx.Provider>
</ChatStateCtx.Provider>
);
}
function useChatState() {
const ctx = useContext(ChatStateCtx);
if (!ctx) throw new Error('Harus di dalam ChatProvider');
return ctx;
}
function useChatDispatch() {
const ctx = useContext(ChatDispatchCtx);
if (!ctx) throw new Error('Harus di dalam ChatProvider');
return ctx;
}
// Komponen
function App() {
return (
<ChatProvider>
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '20px' }}>
<h1>💬 Chat Room</h1>
<UserSwitcher />
<ChatWindow />
<ChatInput />
</div>
</ChatProvider>
);
}
function UserSwitcher() {
const { userAktif } = useChatState();
const dispatch = useChatDispatch();
return (
<div style={{ display: 'flex', gap: '10px', marginBottom: '15px' }}>
<span>Login sebagai:</span>
{Object.values(USERS).map(u => (
<button
key={u.id}
onClick={() => dispatch({ type: 'ganti_user', userId: u.id })}
style={{
padding: '5px 15px',
background: userAktif === u.id ? u.warna : '#eee',
color: userAktif === u.id ? 'white' : 'black',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
}}
>
{u.avatar} {u.nama}
</button>
))}
</div>
);
}
function ChatWindow() {
const { pesan, sedangMengetik, userAktif } = useChatState();
return (
<div style={{
height: '300px',
overflowY: 'auto',
border: '1px solid #ddd',
borderRadius: '8px',
padding: '15px',
marginBottom: '10px',
background: '#fafafa',
}}>
{pesan.map(p => {
const user = USERS[p.userId];
const milikSendiri = p.userId === userAktif;
return (
<div key={p.id} style={{
display: 'flex',
justifyContent: milikSendiri ? 'flex-end' : 'flex-start',
marginBottom: '10px',
}}>
<div style={{
maxWidth: '70%',
padding: '8px 12px',
borderRadius: '12px',
background: milikSendiri ? user.warna : 'white',
color: milikSendiri ? 'white' : '#333',
border: milikSendiri ? 'none' : '1px solid #ddd',
}}>
{!milikSendiri && (
<div style={{ fontSize: '12px', fontWeight: 'bold', marginBottom: '2px' }}>
{user.avatar} {user.nama}
</div>
)}
<div>{p.teks}</div>
<div style={{ fontSize: '10px', opacity: 0.7, textAlign: 'right', marginTop: '2px' }}>
{p.waktu}
</div>
</div>
</div>
);
})}
{/* Indikator mengetik */}
{sedangMengetik && sedangMengetik !== userAktif && (
<div style={{ color: '#999', fontSize: '14px', fontStyle: 'italic' }}>
{USERS[sedangMengetik].avatar} {USERS[sedangMengetik].nama} sedang mengetik...
</div>
)}
</div>
);
}
function ChatInput() {
const { userAktif } = useChatState();
const dispatch = useChatDispatch();
const [teks, setTeks] = useState('');
// Simulasi "sedang mengetik"
useEffect(() => {
if (teks.length > 0) {
dispatch({ type: 'mulai_mengetik' });
} else {
dispatch({ type: 'berhenti_mengetik' });
}
}, [teks, dispatch]);
function handleKirim(e) {
e.preventDefault();
if (!teks.trim()) return;
dispatch({ type: 'kirim_pesan', teks: teks.trim() });
setTeks('');
}
const user = USERS[userAktif];
return (
<form onSubmit={handleKirim} style={{ display: 'flex', gap: '8px' }}>
<span style={{ alignSelf: 'center' }}>{user.avatar}</span>
<input
value={teks}
onChange={(e) => setTeks(e.target.value)}
placeholder={`Ketik pesan sebagai ${user.nama}...`}
style={{ flex: 1, padding: '10px', borderRadius: '20px', border: '1px solid #ddd' }}
/>
<button type="submit" style={{
padding: '10px 20px',
background: user.warna,
color: 'white',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
}}>
Kirim
</button>
</form>
);
}Challenge 3: Project Management Board
Bikin aplikasi manajemen project mini dengan:
- Bisa tambah project
- Setiap project punya tasks
- Tasks bisa dipindah status (todo → doing → done)
- Ada statistik per project
Pakai Reducer + Context. Ini challenge yang menggabungkan SEMUA yang kamu pelajari di Part 4!
Hint: State: { projects: [...], activeProjectId: null }. Setiap project punya array tasks.
Lihat Solusi
import { createContext, useContext, useReducer, useState } from 'react';
// ==================== REDUCER ====================
const initialState = {
projects: [
{
id: 'proj1',
nama: 'Website Portfolio',
tasks: [
{ id: 't1', teks: 'Desain homepage', status: 'done' },
{ id: 't2', teks: 'Coding halaman about', status: 'doing' },
{ id: 't3', teks: 'Setup hosting', status: 'todo' },
],
},
{
id: 'proj2',
nama: 'Aplikasi Todo',
tasks: [
{ id: 't4', teks: 'Buat reducer', status: 'done' },
{ id: 't5', teks: 'Tambah context', status: 'doing' },
],
},
],
activeProjectId: 'proj1',
nextProjectId: 3,
nextTaskId: 6,
};
const STATUS_ORDER = ['todo', 'doing', 'done'];
function projectReducer(state, action) {
switch (action.type) {
case 'tambah_project':
return {
...state,
projects: [...state.projects, {
id: `proj${state.nextProjectId}`,
nama: action.nama,
tasks: [],
}],
nextProjectId: state.nextProjectId + 1,
activeProjectId: `proj${state.nextProjectId}`,
};
case 'pilih_project':
return { ...state, activeProjectId: action.projectId };
case 'hapus_project':
return {
...state,
projects: state.projects.filter(p => p.id !== action.projectId),
activeProjectId: state.activeProjectId === action.projectId
? (state.projects[0]?.id || null)
: state.activeProjectId,
};
case 'tambah_task':
return {
...state,
projects: state.projects.map(p =>
p.id === state.activeProjectId
? {
...p,
tasks: [...p.tasks, {
id: `t${state.nextTaskId}`,
teks: action.teks,
status: 'todo',
}],
}
: p
),
nextTaskId: state.nextTaskId + 1,
};
case 'pindah_task_maju': {
return {
...state,
projects: state.projects.map(p =>
p.id === state.activeProjectId
? {
...p,
tasks: p.tasks.map(t => {
if (t.id !== action.taskId) return t;
const idx = STATUS_ORDER.indexOf(t.status);
if (idx >= STATUS_ORDER.length - 1) return t;
return { ...t, status: STATUS_ORDER[idx + 1] };
}),
}
: p
),
};
}
case 'pindah_task_mundur': {
return {
...state,
projects: state.projects.map(p =>
p.id === state.activeProjectId
? {
...p,
tasks: p.tasks.map(t => {
if (t.id !== action.taskId) return t;
const idx = STATUS_ORDER.indexOf(t.status);
if (idx <= 0) return t;
return { ...t, status: STATUS_ORDER[idx - 1] };
}),
}
: p
),
};
}
case 'hapus_task':
return {
...state,
projects: state.projects.map(p =>
p.id === state.activeProjectId
? { ...p, tasks: p.tasks.filter(t => t.id !== action.taskId) }
: p
),
};
default:
throw new Error('Action tidak dikenal: ' + action.type);
}
}
// ==================== CONTEXT ====================
const ProjectStateCtx = createContext(null);
const ProjectDispatchCtx = createContext(null);
function ProjectProvider({ children }) {
const [state, dispatch] = useReducer(projectReducer, initialState);
return (
<ProjectStateCtx.Provider value={state}>
<ProjectDispatchCtx.Provider value={dispatch}>
{children}
</ProjectDispatchCtx.Provider>
</ProjectStateCtx.Provider>
);
}
function useProjectState() {
const ctx = useContext(ProjectStateCtx);
if (!ctx) throw new Error('Harus di dalam ProjectProvider');
return ctx;
}
function useProjectDispatch() {
const ctx = useContext(ProjectDispatchCtx);
if (!ctx) throw new Error('Harus di dalam ProjectProvider');
return ctx;
}
// ==================== KOMPONEN ====================
function App() {
return (
<ProjectProvider>
<div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'sans-serif' }}>
<Sidebar />
<MainContent />
</div>
</ProjectProvider>
);
}
function Sidebar() {
const { projects, activeProjectId } = useProjectState();
const dispatch = useProjectDispatch();
const [namaProject, setNamaProject] = useState('');
function handleTambah(e) {
e.preventDefault();
if (!namaProject.trim()) return;
dispatch({ type: 'tambah_project', nama: namaProject });
setNamaProject('');
}
return (
<div style={{ width: '250px', background: '#263238', color: 'white', padding: '20px' }}>
<h2 style={{ margin: '0 0 20px' }}>📁 Projects</h2>
{projects.map(p => (
<div
key={p.id}
onClick={() => dispatch({ type: 'pilih_project', projectId: p.id })}
style={{
padding: '10px',
marginBottom: '5px',
background: activeProjectId === p.id ? '#37474f' : 'transparent',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
}}
>
<span>{p.nama}</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>{p.tasks.length}</span>
</div>
))}
<form onSubmit={handleTambah} style={{ marginTop: '20px' }}>
<input
value={namaProject}
onChange={(e) => setNamaProject(e.target.value)}
placeholder="Project baru..."
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: 'none' }}
/>
</form>
</div>
);
}
function MainContent() {
const { projects, activeProjectId } = useProjectState();
const dispatch = useProjectDispatch();
const [taskBaru, setTaskBaru] = useState('');
const project = projects.find(p => p.id === activeProjectId);
if (!project) {
return <div style={{ flex: 1, padding: '40px', textAlign: 'center' }}>Pilih atau buat project</div>;
}
// Statistik
const stats = {
todo: project.tasks.filter(t => t.status === 'todo').length,
doing: project.tasks.filter(t => t.status === 'doing').length,
done: project.tasks.filter(t => t.status === 'done').length,
};
const total = project.tasks.length;
const persen = total > 0 ? Math.round((stats.done / total) * 100) : 0;
function handleTambahTask(e) {
e.preventDefault();
if (!taskBaru.trim()) return;
dispatch({ type: 'tambah_task', teks: taskBaru });
setTaskBaru('');
}
return (
<div style={{ flex: 1, padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1>{project.nama}</h1>
<button
onClick={() => dispatch({ type: 'hapus_project', projectId: project.id })}
style={{ color: 'red', background: 'none', border: '1px solid red', padding: '5px 10px', borderRadius: '4px', cursor: 'pointer' }}
>
Hapus Project
</button>
</div>
{/* Progress */}
<div style={{ background: '#f5f5f5', padding: '15px', borderRadius: '8px', marginBottom: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span>Progress: {persen}%</span>
<span>📋 {stats.todo} | 🔨 {stats.doing} | ✅ {stats.done}</span>
</div>
<div style={{ background: '#ddd', borderRadius: '4px', overflow: 'hidden' }}>
<div style={{ width: `${persen}%`, height: '8px', background: '#4CAF50', transition: 'width 0.3s' }} />
</div>
</div>
{/* Form tambah task */}
<form onSubmit={handleTambahTask} style={{ marginBottom: '20px', display: 'flex', gap: '8px' }}>
<input
value={taskBaru}
onChange={(e) => setTaskBaru(e.target.value)}
placeholder="Task baru..."
style={{ flex: 1, padding: '8px' }}
/>
<button type="submit">+ Tambah</button>
</form>
{/* Kolom Kanban */}
<div style={{ display: 'flex', gap: '15px' }}>
{STATUS_ORDER.map(status => {
const label = { todo: '📋 Todo', doing: '🔨 Doing', done: '✅ Done' };
const tasks = project.tasks.filter(t => t.status === status);
const statusIdx = STATUS_ORDER.indexOf(status);
return (
<div key={status} style={{ flex: 1, background: '#f9f9f9', padding: '10px', borderRadius: '8px' }}>
<h3 style={{ margin: '0 0 10px' }}>{label[status]} ({tasks.length})</h3>
{tasks.map(task => (
<div key={task.id} style={{ background: 'white', padding: '10px', marginBottom: '8px', borderRadius: '4px', boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
<p style={{ margin: '0 0 8px' }}>{task.teks}</p>
<div style={{ display: 'flex', gap: '4px' }}>
{statusIdx > 0 && (
<button onClick={() => dispatch({ type: 'pindah_task_mundur', taskId: task.id })} style={{ fontSize: '12px' }}>←</button>
)}
{statusIdx < STATUS_ORDER.length - 1 && (
<button onClick={() => dispatch({ type: 'pindah_task_maju', taskId: task.id })} style={{ fontSize: '12px' }}>→</button>
)}
<button onClick={() => dispatch({ type: 'hapus_task', taskId: task.id })} style={{ fontSize: '12px', marginLeft: 'auto', color: 'red' }}>✕</button>
</div>
</div>
))}
</div>
);
})}
</div>
</div>
);
}Kesimpulan Part 4: Mengelola State
Selamat! Kamu udah menyelesaikan seluruh Part 4. Mari kita recap perjalanan kamu:
| Bab | Konsep | Analogi |
|---|---|---|
| 1 | Deklaratif vs Imperatif | GPS vs instruksi manual |
| 2 | Struktur State | Lemari yang terorganisir |
| 3 | Lifting State Up | Manajer yang koordinasi divisi |
| 4 | Preserve & Reset State | Loker gym + nomor antrian |
| 5 | Reducer | Kasir restoran yang atur pesanan |
| 6 | Context | Radio FM / intercom gedung |
| 7 | Reducer + Context | Gudang + jaringan distribusi |
Yang kamu sekarang bisa:
- Berpikir deklaratif tentang UI
- Mendesain struktur state yang bersih
- Berbagi state antar komponen dengan lifting state up
- Mengontrol kapan state dipertahankan atau direset
- Mengorganisir logika state kompleks dengan reducer
- Mengirim data ke komponen manapun dengan context
- Menggabungkan reducer + context untuk state management yang scalable
Ini fondasi yang SANGAT kuat. Dengan pemahaman ini, kamu siap untuk membangun aplikasi React yang kompleks dan maintainable. Selamat belajar!
Sudah paham materi ini?
Tandai sebagai selesai untuk melacak progress-mu.