Terakhir diperbaharui: Dec 23, 2020
Menggunakan Redux
Daftar Isi
Persiapan Step by Step - Membuat Slice - Membuat Store - Update Handler - Menampilkan Note - Menambah Note baru - Mengubah Note Final Code TestingPada bagian ini kita akan tambahkan Redux pada aplikasi DinoTes untuk mengelola state.
Tapi ada yang perlu diperhatikan disini, bahwa tanpa Redux aplikasi DinoTes bisa digunakan tanpa ada masalah.
Redux biasa dipakai pada aplikasi React dengan jumlah component yang tidak sedikit, dimana banyak state digunakan oleh banyak component.
Dengan menggunakan Redux semua pengelolaan state terpusat di satu tempat, meskipun begitu Redux bisa membuat aplikasi kita menjadi lebih kompleks dari yang seharusnya.
Sehingga penggunaan Redux disini bersifat optional. Apalagi tujuan penggunaan Redux disini adalah untuk mempelajari konsep state management pada aplikasi React.
Persiapan
Clone repository DinoTes.
Selain redux kita akan menggunakan dua package tambahan, yaitu redux-toolkit & react-redux.
Package tersebut adalah package official dari Redux, direkomendasikan untuk digunakan bersamaan dengan Redux.
Bisakah kita menggunakan Redux tanpa redux-toolkit dan react-redux?
Jawabannya adalah bisa, hanya saja tanpa kedua tools tersebut code yang ditulis di Redux bisa menjadi sangat panjang karena harus mengikuti banyak pola atau pattern.
Jika kamu sudah pernah menggunakan Redux sebelumnya maka kamu tahu apa maksudnya. 🙂
Keduanya menyediakan API yang bisa dimanfaatkan tanpa harus menulis code dari awal.
Sebagai contoh dengan menggunakan API createSlice kita tidak perlu menulis action creators untuk setiap reducer.
Gunakan terminal atau integrated terminal pada VS Code kemudian jalankan perintah berikut untuk menginstall:
1yarn add @reduxjs/toolkit react-redux
Step by Step
Langkah pertama yang kita lakukan adalah membuat 'slice'.
Slice adalah sebuah object yang memiliki tiga bagian utama, yaitu initial state, function reducer dan nama dari slice sebagai identifikasi.
Store pada redux adalah kumpulan dari banyak slice.
Membuat Slice
Buat sebuah folder dengan nama features di dalam folder src, dan subfolder bernama notes.
Susunan folder:
1...2|--src3 |--assets4 |--components5 |--features6 |--notes7 |--layouts8 ...
Kemudian buat sebuah file dengan nama notesSlice.js.
Di dalam file tersebut yang kita lakukan:
- import function createSlice yang berfungsi untuk membuat slice
- membuat initialState untuk data notes
- menambahkan field reducers untuk menampung semua function reducer yang akan dibuat nanti
src/features/notes/notesSlice.js
1import { createSlice } from '@reduxjs/toolkit';23const initialState = [{ id: '', title: '', note: '' }];45const notesSlice = createSlice({6 name: 'notes',7 initialState,8 reducers: {}9});1011export default notesSlice.reducer;
Semua reducer yang ada di dalam slice harus ditambahkan ke Redux store.
Membuat Store
Buat sebuah file dengan nama store.js di dalam src folder.
Salin code berikut:
src/store.js
1import { configureStore } from '@reduxjs/toolkit';23import notesReducer from '../features/notes/notesSlice';45export default configureStore({6 reducer: {7 notes: notesReducer8 }9});
Agar suatu aplikasi React dalam hal ini adalah DinoTes dapat menggunakan store yang baru saja dibuat kita harus memodifikasi file index.js:
1import React from 'react';2import ReactDOM from 'react-dom';3import { BrowserRouter } from 'react-router-dom';4import { Provider } from 'react-redux';56import './index.css';7import App from './App';8import * as serviceWorker from './serviceWorker';910import store from './store.js';1112ReactDOM.render(13 <React.StrictMode>14 <Provider store={store}>15 <BrowserRouter>16 <App />17 </BrowserRouter>18 </Provider>19 </React.StrictMode>,20 document.getElementById('root')21);2223// If you want your app to work offline and load faster, you can change24// unregister() to register() below. Note this comes with some pitfalls.25// Learn more about service workers: https://bit.ly/CRA-PWA26serviceWorker.unregister();
Update handler
Kita perlu update semua handler yang bertanggung jawab untuk menghandle user action seperti menambah note, mengedit note dan menampilkan note.
Update ini meliputi:
Membuat reducer di dalam slice untuk setiap action
Menambah function dispatch untuk meneruskan semua action yang didapat dari event handler di UI ke Redux store
Hal ini dikarenakan semua state harus diupdate di dalam slice menggunakan function reducer. Dan slice berada di dalam Redux store.
- Menambahkan middleware pada store untuk menghandle proses asynchronous yang terjadi ketika fetching data dari server/api
Menampilkan Note
Data notes yang berasal dari server tidak lagi ditampilkan secara langsung pada component, namun harus melalui Redux.
Untuk menampilkan semua note tambahkan code berikut:
src/features/notes/notesSlice.js
1...2export const getAllNotes = state => state.notes;3...
Buat middleware untuk menghandle proses fetching data dari server. Kita gunakan Redux toolkit API createAsyncThunk.
1import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';23const initialState = [{ id: '', title: '', note: '' }];45const notesSlice = createSlice({6 name: 'notes',7 initialState,8 reducers: {}9});1011export const fetchNotes = createAsyncThunk('notes/fetchNotes', async () => {12 const response = await fetch(`${process.env.REACT_APP_API_URL}/notes`);13 return response.notes;14});1516export const getAllNotes = (state) => state.notes;1718export const { noteAdded } = notesSlice.actions;1920export default notesSlice.reducer;
createAsyncThunk menerima dua argument:
- string sebagai nama untuk action types yang digenerate
- 'payload creator', callback function yang menghasilkan Promise berisi data atau pesan kesalahan
Kenapa harus menggunakan middleware?
Redux store pada dasarnya tidak 'mengerti' apa itu logika asynchronous. Diantara tugas Redux store adalah melakukan proses dispatch action, update state dengan cara memanggil function reducer dan memberi tahu UI bahwa state telah berubah.
Dengan menambahkan sebuah middleware diantara dispatch & reducer, kita bisa intercept action yang sudah di-dispatch sebelum diteruskan ke reducer.
Contoh bentuk intercept:
menambahkan function untuk logging
membuat function dispatch menerima value selain object action seperti function atau promise
Pada kasus ini object action dihasilkan oleh middleware dan bukan berasal dari dispatch.
Selain itu Redux menggunakan konsep functional programming, hal ini bisa dilihat dari bentuk function reducer yang harus ditulis dalam bentuk pure function sehingga tidak ada ruang untuk 'side effect'.
Semua operasi yang memiliki 'side effect' dilakukan di dalam Redux middleware.
Sehingga Redux middleware menjadi tempat paling cocok untuk melakukan proses asynchronous seperti fetching data dari server.
Memantau progress dari operasi asynchronous
Kita bisa memantau progress dari suatu proses async (API call) sebagai suatu state.
Value dari state ini berada diantara 4 kondisi:
- Request belum dikirim (idle)
- Request masih dalam proses
- Request berhasil
- Request gagal
Kita ubah initialState agar bisa menyimpan data dari notes, status dan error:
1...2const initialState = {3 data: [],4 status: 'idle',5 error: null6}7...
Isi dari state yang ada di dalam store sekarang adalah:
- data, menyimpan semua data notes
- status, menyimpan status dari proses async
- error, menyimpan error jika ada error
Kemudian update slice agar bisa menghandle hasil promise dari createAsyncThunk dengan menambahkan extraReducer.
1import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';23const initialState = {4 data: [],5 status: 'idle',6 error: null7};89export const fetchNotes = createAsyncThunk('notes/fetchNotes', async () => {10 const response = await fetch(`${process.env.REACT_APP_API_URL}/notes`);11 const data = await response.json();12 return data;13});1415const notesSlice = createSlice({16 name: 'notes',17 initialState,18 reducers: {},19 extraReducers: {20 [fetchNotes.pending]: (state, action) => {21 state.status = 'loading';22 },23 [fetchNotes.fulfilled]: (state, action) => {24 state.status = 'succeeded';25 state.data = action.payload;26 },27 [fetchNotes.rejected]: (state, action) => {28 state.status = 'failed';29 state.error = action.error.message;30 }31 }32});3334export const getAllNotes = (state) => state.notes.data;3536export default notesSlice.reducer;
- Ketika proses fetching dimulai, status kita ubah dari 'idle' ke 'loading'
- Jika proses fetching berhasil, kita update status ke 'succeeded'
- Sebaliknya, jika gagal kita update status ke 'failed'
Selanjutnya adalah menambah function dispatch pada component sebagai 'penghubung' antara component dan store.
Setiap data atau event yang ditangkap oleh event handler di component akan diteruskan ke store oleh dispatch sebagai sebuah action.
Update code dari src/components/NotesList.js:
1import React, { useEffect } from 'react';2import styled from 'styled-components';3import { Link } from 'react-router-dom';4import { useSelector, useDispatch } from 'react-redux';56import { getAllNotes, fetchNotes } from '../features/notes/notesSlice';78const NotesListContainer = styled.div`91011121314151617`;1819const List = styled.ul`2021`;2223const ListItem = styled.li`2425`;2627const Separator = styled.hr`2829303132`;3334const NotesList = () => {35 const dispatch = useDispatch();36 const notes = useSelector(getAllNotes);37 const notesStatus = useSelector((state) => state.notes.status);38 const error = useSelector((state) => state.notes.error);3940 useEffect(() => {41 if (notesStatus === 'idle') {42 dispatch(fetchNotes());43 }44 }, [notesStatus, dispatch]);4546 let content;4748 if (notesStatus === 'loading') {49 content = <div>Loading...</div>;50 } else if (notesStatus === 'succeeded') {51 content = (52 <List>53 {notes.map((note) => {54 return (55 <ListItem key={note._id}>56 <h4>57 <Link to={`/edit/${note._id}`}>{note.title}</Link>58 </h4>59 <p>{note.note.slice(0, 101)}</p>60 <Separator />61 </ListItem>62 );63 })}64 </List>65 );66 } else if (notesStatus === 'failed') {67 content = <div>{error}</div>;68 }6970 return <NotesListContainer>{content}</NotesListContainer>;71};7273export default NotesList;
Penjelasan code di atas:
- Import function untuk menampilkan semua notes dan function untuk fetching data
1import { getAllNotes, fetchNotes } from '../features/notes/notesSlice';
- Mengambil data notes, status dari operasi fetching API dan error menggunakan useSelector
1const notes = useSelector(getAllNotes);2const notesStatus = useSelector((state) => state.notes.status);3const error = useSelector((state) => state.notes.error);
- Pengambilan (fetching) data dimulai ketika status adalah idle dan dilakukan di dalam useEffect() hooks
1useEffect(() => {2 if (notesStatus === 'idle') {3 dispatch(fetchNotes());4 }5}, [notesStatus, dispatch]);
Kita hanya melakukan proses fetching ketika value dari notesStatus dan dispatch berubah(line 5).
- Conditional rendering berdasarkan status
1if (notesStatus === 'loading') {2 content = <div>Loading...</div>;3} else if (notesStatus === 'succeeded') {4 content = (5 <List>6 {notes.map((note) => {7 return (8 <ListItem key={note._id}>9 <h4>10 <Link to={`/edit/${note._id}`}>{note.title}</Link>11 </h4>12 <p>{note.note.slice(0, 101)}</p>13 <Separator />14 </ListItem>15 );16 })}17 </List>18 );19} else if (notesStatus === 'failed') {20 content = <div>{error}</div>;21}
Sekarang fetching data dilakukan melalu Redux middleware dan component akan dirender sesuai value dari state yang ada di dalam slice/store.
Component atau UI dari aplikasi kini hanya bertugas untuk menampilkan perubahan sesuai dengan apa yang terjadi di dalam store.
Menambah Note baru
Buat sebuah middleware untuk mengirim note yang baru ke server:
1...2export const addNewNote = createAsyncThunk(3 "notes/AddNewNote",4 async (initialNotes) => {5 const requestOptions = {6 method: "POST",7 headers: { "Content-Type": "application/json" },8 body: JSON.stringify(initialNotes),9 };1011 const response = await fetch(12 `${process.env.REACT_APP_API_URL}/note`,13 requestOptions14 );15 if (response.ok) {16 const data = await response.json();17 const noteAdded = { ...initialNotes, _id: data._id };18 return noteAdded;19 } else {20 return null;21 }22 }23);24...
Untuk menampilkan data terbaru termasuk yang baru saja ditambahkan kita bisa gunakan dua cara:
Gunakan useSelector dan function getAllNotes untuk mengambil data dari store
Kelebihan menggunakan cara ini adalah user dapat melihat semua note yang dimiliki dengan cepat karena memang data yang ditampilkan adalah data di sisi client.
Tetapi kita harus selalu pastikan bahwa data yang ada di sisi client adalah data yang sama dengan yang ada di server.
Fetching data terbaru dari server setiap kali user membuka halaman Home
Dengan cara kedua, data notes yang ditampilkan adalah data yang sama dengan yang ada di server.
Tetapi cara ini membuat aplikasi DinoTes harus lebih sering mengirim network request dan jika terjadi masalah network/koneksi maka user tidak akan bisa melihat semua note yang dimiliki.
Kita akan pilih cara kedua, data yang ditampilkan lebih konsisten dan jika terjadi masalah dengan koneksi kita bisa tampilkan pesan kesalahan kepada user.
Yang perlu dilakukan adalah menambah function reducer yang bertugas untuk mereset status menjadi idle, hal ini akan membuat proses fetching dilakukan setiap kali user membuka halaman utama / Home.
1...2 statusReset(state, action) {3 state.status = "idle";4 }5...
Versi akhir dari code:
src/features/notes/notesSlice.js
1import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';23const initialState = {4 data: [],5 status: 'idle',6 error: null7};89export const fetchNotes = createAsyncThunk('notes/fetchNotes', async () => {10 const response = await fetch(`${process.env.REACT_APP_API_URL}/notes`);11 const data = await response.json();12 return data;13});1415export const addNewNote = createAsyncThunk('notes/AddNewNote', async (initialNotes) => {16 const requestOptions = {17 method: 'POST',18 headers: { 'Content-Type': 'application/json' },19 body: JSON.stringify(initialNotes)20 };2122 const response = await fetch(`${process.env.REACT_APP_API_URL}/note`, requestOptions);23 if (response.ok) {24 const data = await response.json();25 const noteAdded = { ...initialNotes, _id: data._id };26 return noteAdded;27 } else {28 return null;29 }30});3132const notesSlice = createSlice({33 name: 'notes',34 initialState,35 reducers: {36 statusReset(state, action) {37 state.status = 'idle';38 }39 },40 extraReducers: {41 [fetchNotes.pending]: (state, action) => {42 state.status = 'loading';43 },44 [fetchNotes.fulfilled]: (state, action) => {45 state.status = 'succeeded';46 state.data = action.payload;47 },48 [fetchNotes.rejected]: (state, action) => {49 state.status = 'failed';50 state.error = action.error.message;51 },52 [addNewNote.pending]: (state, action) => {53 state.status = 'loading';54 },55 [addNewNote.fulfilled]: (state, action) => {56 state.status = 'succeeded';57 state.data.push(action.payload);58 },59 [addNewNote.rejected]: (state, action) => {60 state.status = 'failed';61 state.error = action.error.message;62 }63 }64});6566export const getAllNotes = (state) => state.notes.data;6768export const { statusReset } = notesSlice.actions;6970export default notesSlice.reducer;
Data note yang sekarang disimpan masih kurang satu field yang penting, yaitu field id.
id ini diperlukan untuk proses rendering pada component.
Oleh karena itu handler pada sisi backend/api yang bertugas untuk menyimpan sebuah note baru ke dalam database perlu diupdate agar bisa mengirimkan id dari setiap note yang berhasil disimpan. Dalam hal ini adalah _id yang berasal dari MongoDB.
Buka project dinotes-api, update file handler.js:
1...2exports.addNote = async (req, res, next) => {3 const { notesCollection } = req.app.locals;4 const { title } = req.body;56 try {7 if (!title) {8 logger.error(`${req.originalUrl} - ${req.ip} - title is missing `);9 throw new Error('title is missing');10 }11 // Insert data to collection12 const result = await notesCollection.insertOne(req.body);1314 const objResult = JSON.parse(result);1516 logger.info(`${req.originalUrl} - ${req.ip} - Data successfully saved`);1718 res.status(200).json({ message: 'Data successfully saved', _id: objResult.insertedId });19 } catch (error) {20 logger.error(`${req.originalUrl} - ${req.ip} - ${error} `);21 next(error);22 }23};24...
Langkah selanjutnya adalah update code dari component yang bertugas untuk menambah note baru.
src/components/AddNoteForm.js
Update event handler handleSubmit:
1...2 const handleSubmit = async (e) => {3 e.preventDefault();45 try {6 const actionResult = await dispatch(addNewNote(state));7 const result = unwrapResult(actionResult);8 if(result) {9 setIsSuccess(true);10 } else {11 setIsSuccess(false);12 }13 } catch (err) {14 console.error("Terjadi kesalahan: ", err);15 setIsSuccess(false);16 } finally {17 dispatch(statusReset())18 }19 };20...
Ketika dispatch dieksekusi, async thunk akan kembali dengan sebuah Promise. Untuk mengetahui value dari Promise ini apakah berhasil atau tidak kita bisa gunakan sebuah function bernama unwrapResult().
Versi akhir code:
src/components/addNoteForm.js
1import React, { useState } from 'react';2import { unwrapResult } from '@reduxjs/toolkit';3import { useDispatch } from 'react-redux';4import { Form, FormGroup, Label, Input, TextArea } from './ui/Form';5import Button from './ui/Button';6import Message from './ui/Message';7import { addNewNote, statusReset } from '../features/notes/notesSlice';89const InfoWrapper = (props) => {10 const { status } = props;1112 if (status !== null) {13 if (status === false) {14 return <Message type="error" text="Title harus diisi" />;15 }16 return <Message type="success" text="Data berhasil disimpan" />;17 }18 return <></>;19};2021const AddNoteForm = () => {22 const dispatch = useDispatch();23 const [state, setState] = useState({ title: '', note: '' });24 const [isSuccess, setIsSuccess] = useState(null);2526 const handleTitleChange = (e) => {27 setState({ ...state, title: e.target.value });28 };2930 const handleNoteChange = (e) => {31 setState({ ...state, note: e.target.value });32 };3334 const handleSubmit = async (e) => {35 e.preventDefault();3637 try {38 const actionResult = await dispatch(addNewNote(state));39 const result = unwrapResult(actionResult);40 if (result) {41 setIsSuccess(true);42 } else {43 setIsSuccess(false);44 }45 } catch (err) {46 console.error('Terjadi kesalahan: ', err);47 setIsSuccess(false);48 } finally {49 dispatch(statusReset());50 }51 };5253 const { title, note } = state;5455 return (56 <>57 <InfoWrapper status={isSuccess} />58 <Form onSubmit={handleSubmit}>59 <FormGroup>60 <Label>Title</Label>61 <Input type="text" name="title" value={title} onChange={handleTitleChange} />62 </FormGroup>63 <FormGroup>64 <Label>Note</Label>65 <TextArea name="note" rows="12" value={note} onChange={handleNoteChange} />66 </FormGroup>67 <FormGroup>68 <Button type="submit">Add</Button>69 </FormGroup>70 </Form>71 </>72 );73};7475export default AddNoteForm;
Mengubah Note
Edit disini meliputi update dan delete.
Sama dengan langkah sebelumnya, kita akan lakukan hal berikut:
- Membuat middleware / async thunk yang berkomunikasi dengan API untuk melakukan operasi update/delete
- Membuat extraReducer yang akan bertugas untuk mengubah state pada setiap status (loading, succeeded, failed)
- Mengupdate event handler dengan menambah method dispatch
Update
Buat middleware yang bertugas untuk mengirim request update ke API server:
src/features/notes/notesSlice.js
1...2export const updateExistingNote = createAsyncThunk(3 "notes/updateNote",4 async(currentNote) => {5 const requestOptions = {6 method: 'PUT',7 headers: { 'Content-Type': 'application/json' },8 body: JSON.stringify(currentNote)9 };1011 const response = await fetch(`${process.env.REACT_APP_API_URL}/note/${currentNote._id}`, requestOptions);12 if (response.ok) {13 return currentNote;14 } else {15 return null;16 }17 }18);19...
Tambah extraReducer:
1...2 [updateExistingNote.pending]: (state, action) => {3 state.status = "loading";4 },5 [updateExistingNote.fulfilled]: (state, action) => {6 state.status = "succeeded";7 const { _id, title, note } = action.payload;8 const existingNote = state.data.find(note => note._id === _id);9 if (existingNote) {10 existingNote.title = title;11 existingNote.note = note;12 }13 },14 [updateExistingNote.rejected]: (state, action) => {15 state.status = "failed";16 state.error = action.error.message;17 },18...
Buat sebuah function untuk menampilkan note berdasarkan note Id:
1...2export const getNoteById = (state, noteId) =>3 state.notes.data.find(note => note.id === noteId);4...
Update event handler handleSubmit di component:
src/components/EditNoteForm.js
1...2 const handleSubmit = async (e) => {3 e.preventDefault();45 try {6 const actionResult = await dispatch(updateExistingNote(state));7 const result = unwrapResult(actionResult);8 if(result) {9 setIsSuccess(true);10 } else {11 setIsSuccess(false);12 }13 } catch (err) {14 console.error("Terjadi kesalahan: ", err);15 setIsSuccess(false);16 } finally {17 dispatch(statusReset());18 }19 };20...
Delete
Buat middleware yang bertugas untuk mengirim request delete ke API server:
src/features/notes/notesSlice.js
1...2export const deleteNote = createAsyncThunk(3 "notes/deleteNote",4 async(currentNote) => {5 const requestOptions = {6 method: 'DELETE',7 headers: { 'Content-Type': 'application/json' }8 };910 const response = await fetch(`${process.env.REACT_APP_API_URL}/note/${currentNote._id}`, requestOptions);11 if (response.ok) {12 return currentNote;13 }14 }15)16...
Tambahkan reducer di field extraReducer:
1...2 [deleteNote.pending]: (state, action) => {3 state.status = "loading";4 },5 [deleteNote.fulfilled]: (state, action) => {6 state.status = "succeeded";7 const { _id } = action.payload;8 const updatedNotes = state.data.filter(note => note._id === _id);9 state.data = updatedNotes;10 },11 [deleteNote.rejected]: (state, action) => {12 state.status = "failed";13 state.error = action.error.message;14 },15...
Update event handler handleDeleteNote:
src/components/EditNoteForm.js
1...2 const handleDeleteNote = async (e) => {3 e.preventDefault();45 try {6 const actionResult = await dispatch(deleteNote(state));7 const result = unwrapResult(actionResult);8 if(result) {9 setIsSuccess(true);10 } else {11 setIsSuccess(false);12 }13 } catch (err) {14 console.error("Terjadi kesalahan: ", err);15 setIsSuccess(false);16 } finally {17 dispatch(statusReset());18 history.push("/");19 }20 };21...
Final Code
Versi akhir dari code cukuplah panjang, kamu bisa lihat di repository dinotes-client pada branch redux.
Testing
Kamu bisa jalankan perintah yarn start
pada setiap project (client & api) untuk melihat apakah aplikasi DinoTes tetap berjalan dengan baik setelah pengelolaan state diubah dari internal state management (props & state) ke Redux.
Seperti yang sudah disebutkan sebelumnya, code dari aplikasi DinoTes sekarang menjadi lebih kompleks. Dimana untuk update satu state saja banyak proses yang harus dilalui yaitu event handler -> dispatch action -> reducer -> update state.
Sesuai rekomendasi dari Redux, gunakan Redux jika:
- Banyak data yang berubah dari waktu ke waktu
- Pengelolaan state harus dilakukan di satu tempat (Single source of truth)
- Mengelola state di top-level component sudah tidak lagi relevan
Intinya jika tanpa Redux tidak ada masalah, maka tidak perlu gunakan Redux