Bab 7: Scaling Up dengan Reducer dan Context

5 menit baca

Pendahuluan: 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

jsx
// 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

jsx
// 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

jsx
// 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)

jsx
// 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

jsx
// 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

jsx
// 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!

jsx
// 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:

jsx
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

💡Info

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

  1. Testable: Reducer bisa di-test tanpa render komponen
  2. Reusable: Provider bisa dipakai di mana aja
  3. Maintainable: Mau tambah fitur? Tambah case di reducer, gak perlu ubah UI
  4. Debuggable: Semua perubahan state bisa dilacak lewat action types

Testing Reducer (Bonus)

Salah satu keuntungan terbesar reducer: mudah di-test!

jsx
// 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):

jsx
// 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' }),
};
jsx
// 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

jsx
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?

SituasiRekomendasi
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 developerPertimbangkan Redux/Zustand
Perlu middleware (logging, async)Pertimbangkan Redux/Zustand
Perlu devtools yang canggihPertimbangkan Redux
Mau yang simpel tapi powerfulZustand 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

jsx
// ❌ 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

jsx
// ❌ 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

jsx
// ❌ "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

jsx
// ❌ 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
jsx
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
jsx
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
jsx
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:

BabKonsepAnalogi
1Deklaratif vs ImperatifGPS vs instruksi manual
2Struktur StateLemari yang terorganisir
3Lifting State UpManajer yang koordinasi divisi
4Preserve & Reset StateLoker gym + nomor antrian
5ReducerKasir restoran yang atur pesanan
6ContextRadio FM / intercom gedung
7Reducer + ContextGudang + jaringan distribusi

Yang kamu sekarang bisa:

  1. Berpikir deklaratif tentang UI
  2. Mendesain struktur state yang bersih
  3. Berbagi state antar komponen dengan lifting state up
  4. Mengontrol kapan state dipertahankan atau direset
  5. Mengorganisir logika state kompleks dengan reducer
  6. Mengirim data ke komponen manapun dengan context
  7. 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.