Billedresultat for react redux Exercise: React Redux Toolkit part 3 (Zealand Car Collections)

Idea: Introduction to Redux Toolkit.
Background: https://redux-toolkit.js.org/introduction/getting-started og Usage Guide
Crediting: Opgaven er baseret på eksempel fra Udemy kurset: Modern React with Redux [2023]

 

I denne opgave skal der laves en lille React-Redux App baseret på Redux-Toolkit. Applikationen vil benytte ConfigStore til konfigurering af Redux Store samt createSlice til at oprette Slices til storen.
Der skal desuden anvendes Hooks (useSelector, useDispatch), reducers og Array-helper metoderne filter og reduce. Der benyttes desuden et moderne CSS framework Bulma ( https://bulma.io/ ) til styling.

Appen der skal udvikles skal se således ud:


Det er en lille applikation der kan benyttes til at holde "styr" på de biler der er ejet af Zealand (he he).
Det skal være muligt at:


Step 1 (Opstart - installation af Node pakker )
Opret et nyt projekt med kommandoen: 'npx create-react-app cars' i din ReactRedux mappen (eller hvor du ellers finder det passende at have dit nye projekt).
Naviger til mappen 'cd cars' og kør: 'npm install @reduxjs/toolkit react-redux bulma' for at insallere de nødvendige node-moduler inklusiv bulma til stylling af appen.

Kør 'npm start' for at afprøve at installationen er gået godt - du skulle gerne se følgende i din browser:



Når du har verificeret at appen køre slettes hele indholdet af src-mappen.

Step 2 (App.js og index.js)
Opret en ny fil i src-mappen: App.js - filen skal indeholde:

function App() {
  return (
    <div className="container is-fluid">
      <h1 className="title is-2">Zealand Car Collections</h1>
    </div>
  );
}
export default App;

Opret tilsvarende filen index.js med følgende indhold:

import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

const el = document.getElementById('root');
const root = createRoot(el);

root.render(<App />);


Verificer at du får vist følgende i browseren:


Vi har nu fået alt "boilerplate" koden på plads og kan begynde at "designe" vores React-Redux-Toolkit app. Ved et nærmere kik på appens side kan vi se at den naturligt kan opdeles i 4 komponenter: CarForm til oprettelse af en ny bil, CarSearch til søgning/filtrering af biler, CarList til at vise bilerne og CarValue til at vise total prisen.





Step 3 (components - CarForm.js, CarList.js, CarSearch.js og CarValue.js)
Opret en ny mappe components med filerne: CarForm.js, CarList.js, CarSearch.js og CarValue.js

Alle filerne skal til at starte med blot indeholde en simpel definition på funktions komponenter ala:

function CarForm() {
    return <div>CarForm</div>;
}
export default CarForm;



Step 4 (Opdater App.js)

Tilføj import statements til de nye komponenter og tilføj tags med komponenterne så følgende vises i browseren:

 

Nu vi har identificeret og klargjort komponenterne er det tid til at få designet store og slices. Selve Redux Storen kommer til at indeholde en state for name, cost og searchTerm der skal indeholde values fra input-felterne samt cars der skal indeholde data om de enkelte biler. Bemærk at totalCost ikke behøver sin egen state da den kan udledes (derived) dvs beregnes ud fra data i arrayet cars.

Da name og cost naturligt hører sammen og er knyttet til samme component vil vi placere dem i samme slice: formSlice. searchTerm og cars (data) høre også naturligt sammen og vil blive placeret i samme slice: carsSlice.

Næste skridt er at bestemme hvilke reducere der skal være i de 2 slices.

 

Step 5 (Store og Slices - store.js, formSlice.js og carSlice.js)
Opret en mappe: store der skal indeholde mappen slices. Opret filerne formSlice.js og carsSlice.js i mappen slices under store.

a) formSlice.js
Først skal 'createSlice' importeres fra '@reduxjs/toolkit' og der skal laves et nyt slice objekt, hvor

Endeligt skal "mini-reducer-functions" og "combined-reducer" eksporters.

Bemærk: "mini-reducer-functions" er en slags "action-creators" da de returnerer et "action-objekt"

Følgende kode indsættes i formSlice.js:

import {createSlice} from '@reduxjs/toolkit';
const formSlice = createSlice({
    name: 'form',
    initialState: {
        name: '',
        cost: 0
    },
    reducers:{
        changeName(state, action){
            state.name = action.payload;
        },
        changeCost(state, action){
            state.cost = action.payload;
        }
    }
})
export const {changeName, changeCost} = formSlice.actions; //"mini-reducer-functions" - action creators
export const formReducer = formSlice.reducer;  //combined reducer

 

b) carsSlice.js
På tilsvarende måde skal vi have carsSlice implementeret. Her vil vi benytte nanoid fra redux-toolkit til at autogenerere et unikt id.
Bemærk vi kalder staten til cars for data (så der ikke er navne sammenfald med slice'ens name).
Bemærk også at vi antager, at payload til addCar har formatet: {name: '...', cost: ..} og til removeCar formatet: id - der er id på den car der skal slettes

Følgende kode indsættes i carsSlice.js:

import {createSlice, nanoid} from '@reduxjs/toolkit';
const carsSlice = createSlice({
    name: 'cars',
    initialState: {
        searchTerm: '',
        data: [],
    },
    reducers:{
        changeSearchTerm(state, action){
            state.searchTerm = action.payload;
        },
        //Assumption: action.payload === {name: 'xx', cost: yy}
        addCar(state, action){
            state.data.push({
                name: action.payload.name,
                cost: action.payload.cost,
                id : nanoid()
            });
        },
        //Assumption: action.payload === id of the car to be removed
        removeCar(state, action){
            state.data = state.data.filter((car) => {return car.id !== action.payload})
        }
    }
})
export const {changeSearchTerm, addCar, removeCar} = carsSlice.actions;
export const carsReducer = carsSlice.reducer;  //combined reducers

 

c) store/index.js
Først skal configureStore importeres fra '@reduxjs/toolkit' og alle combined-reducere og mini-reducer-functions importeres fra slice'ene. Egentligt er det tilstrækkeligt at importere combined-reducers, da det er dem der skal registreres i configurationen af storen, men ved at eksportere alle mini-reducerne sammen med storen, behøver man kun en import fra storen og ikke mange sætninger med import af de enkelte slices.

Følgende kode indsættes i store/index.js:

import { configureStore } from "@reduxjs/toolkit";
import { carsReducer, addCar, removeCar, changeSearchTerm} from './slices/carsSlice';
import { formReducer, changeName, changeCost } from "./slices/formSlice";
const store = configureStore({
    reducer: {
        cars: carsReducer,
        form: formReducer
    }
})
//Bemærk ved at eksportere alle reducerne samlet behøver man ikke importere alle slices men blot den samlede store hvor den skal benyttes
export { store, changeName, changeCost, addCar, removeCar, changeSearchTerm };

 

d) src/index.js
Vi skal nu tilføje react-redux til vores app, det gøres ved at importere Provider fra 'react-redux' og vores nye store fra './store':

Følgende kode tilføjes i src/index.js:

import { Provider } from 'react-redux';
import { store } from './store';


<
App> skal wrappes ind i <Provider store={store}>:

root.render(
        <Provider store={store}>
            <App />
        </Provider>
);



Verificer at appen stadigt køre og at der ikke er meldt fejl i consolen - det skulle gerne stadigt se således ud:

 


Efter vi har haft fokus på Redux delen af vores applikation er de på tide at rette fokus mod React delen og dermed få noget indhold på komponenterne.

Step 6 (CarForm.js)
Først skal vi ha udsiftet det midlertidige <div> tag med "CarList" til et <div> tag der indeholder overskriften "Add Car" samt et <form> tag med labels, input-field og submit button, så user kan indtaste name og cost til rn ny bil.

Indsæt følgende kode i CarForm.js:

function CarForm() {
   
    return (
        <div className="car-form panel">
          <h4 className="subtitle is-3"> Add Car</h4>
          <form onSubmit={handleSubmit}>
          <div className="field-group">
            <div className="field">
              <label className="label">Name</label>
              <input className="input is-expanded" value={name} onChange={handleNameChange} />
            </div>
            <div className="field">
              <label className="label">Cost</label>
              <input className="input is-expanded" value={cost || ''} onChange={handleCostChange} type="number" />  
            </div>
            </div>
            <div className="field">
              <button className="button is-link">Submit</button>
            </div>
          </form>
        </div>
        );
    }
   
export default CarForm;

Bemærk: name, handelNameChange, cost og handleCostChange i de to <input> tag samt handleSubmit i <form> tag er ikke defineret endnu!
Bemærk: i <input> til cost benyttes value = {cost || ' '} og type="number", ellers vil der default stå '0' og det kan ikke slettes samt det vil være muligt at skrive bogstaver ind i feltet.
Bemærk: der er benyttet en række className="...". det er af hensyn til senere styling.

name og cost skal bindes til storen, mere præcist til state name og state cost i formSlice, det gøres ved at benytte hook-metoden useSelector( ), fx:

Ved at benytte destructoring, kan disse linier sammenskrives til:

const {name, cost} = useSelector((state) => {
    return {
      name: state.form.name,
      cost: state.form.cost
    }
  });


a) handleNameChange
Når et onChange - event "affyres" fra et <input> kan den af useren indtastede værdi tilgås med: event.target.value. Denne value gives som argument til "mini-reduceren" changeName(event.target.value). "mini-reduceren" fungere som en action-creator som returnere et action-objekt der kan gives til dispatcheren:

const handleNameChange = (event) => {
    dispatch(changeName(event.target.value));
  };



b) handleCostChange
Som argument til changeCost reduceren giver vi argumentet 'parseInt(event.target.value) || 0' da value skal konverteres til et heltal, '|| 0' for at undgå NaN (Not a Number) hvis user indtaster noget der ikke er et tal:

const handleCostChange = (event) => {
    dispatch(changeCost(parseInt(event.target.value) || 0));  //hvis user indtaster noget der ikke er et tal fås NaN - Not a Number, derfor || 0
  };

 

c) handleSubmit
Vi benytter event.perventDefault( ) for at forhindre Browseren i at udføre et automatisk default submit.
Bemærk vi havde antaget at payload til addCar reduceren var af formen: {name: ... , cost: ...}, men da både name og value hedder det samme, kan det sammenskrives til blot: {name, cost}:

const handleSubmit = (event) => {
    event.preventDefault();                //dette for at undgå at Browseren automatisk prøver et udføre et submit
    dispatch(addCar({name, cost}));
  }

 

d) Import hooks og reducers
Til slut mangler vi blot at importere hooks og reducere samt at få adgang til dispatch'eren:

import { useDispatch, useSelector } from "react-redux";
import { changeName, changeCost, addCar } from "../store";

function CarForm() {
  const dispatch = useDispatch();

 

Verificer at appen stadigt køre og at der ikke er meldt fejl i consolen - det skulle gerne stadigt se således ud:

Step 7 (CarList.js)
CarList komponenten skal nu opdateres i stil med CarForm komponenten:

import { useSelector, useDispatch } from "react-redux";
import { removeCar } from "../store";
function CarList() {
  const dispatch = useDispatch();
  const cars = useSelector(({cars: {data, searchTerm}}) => { //destructoring state to {cars: {data, searchTerm}} - vi er kun interesseret i data og searchTerm
    return data.filter((car) => car.name.toLowerCase().includes(searchTerm.toLowerCase()) );
  });
  const handleRemoveCar = (car) => {
    dispatch(removeCar(car.id));
  }
 
const renderedCars = cars.map((car) => {
  return (
    <div key={car.id} className="panel">
      <p>
        {car.name} - {car.cost} kr.
      </p>
      <button className="button is-danger" onClick={() => handleRemoveCar(car) }>
        Delete
      </button>
    </div>
  )
})
  return (
  <div className="car-list">
    {renderedCars}
    <hr />
   </div>
  );
}
export default CarList;

 

Gennemgå koden og se om du forstår hvad der sker, fx hvordan map anvendes og hvorfor key-attributtet sættes i <div key={car.id}> tagget.

Verificer at appen stadigt køre og at der ikke er meldt fejl i consolen - det skulle gerne stadigt se således ud:

 


Afprøv at der kan oprettes biler og slettes biler.

 

Step 8 (styles.css og src/index.js)
Det er nu tid til at lave lidt styling, du er selvfølgelig velkommen til at lave din egen css fil, men du kan også blot downloade: styles.css og indsætte den i din src-mappe. Bemærk vi benytter et css-framework der hedder Bulma: ( https://bulma.io/ ) og som blev installeret under step 1.

Husk at importe både bulma.css og style.css i src/index.js filen:

import 'bulma/css/bulma.css';
import './styles.css';

 

Verificer at appen ny er stylet og ser ud som følger:

 

Bemærk at efter submit bliver input felterne ikke nulstillet, det vil vi gøre noget ved i næste step.

 

Step 9 (Reset af form inputfelter efter submit - CarForm.js, formSlice.js)
Umiddelbart er det nærliggende i at tilføje: dispatch(changeCost(0) og dispatch(changeName(' ') efter dispatch(addCar(...) i handleSubmit funktionen.
Afprøv at det faktisk virker.


Det er i midlertid ikke en skalerbar løsning bare at kalde en række dispatch'er funktioner til reset af større forms med mange input felter. Vi vil istedet benytte en alternativ løsning og udnytte at når der trykkes på submit button vil den kalde handleSubmit der igen kalder dispatch med et action-objekt af formen: {type: 'cars/addCar'} (toolkit laver dette objekt for os). Dispatcheren vil automatisk kalle alle Combined-reducere (dvs både Cars og Form). Pt er det kun Cars reduceren addCar der håndtere denne type action, men vi kan extende Form reduceren til også at håndtere denne type actions vha extraReducers.

Tilføj den ekstra reducer til formSlice i formSlice.js (under reducers - ps husk komma efter reducers: {...}, ):

extraReducers(builder){
        builder.addCase(addCar, (state, action) => {
            state.name = '';
            state.cost = 0;
        });
    }

og importer addCar fra './carsSlice':

import { addCar } from './carsSlice';


Afprøv at det virker.

 

 

Step 10 (CarSearch.js)
I dette step skal vi implementere CarSearch komponenten færdig, erstat den eksisterende kode med:

import { useSelector, useDispatch } from "react-redux";
import { changeSearchTerm } from "../store";

function CarSearch() {
  const dispatch = useDispatch();
  const searchTerm = useSelector((state) => {
    return state.cars.searchTerm;
  });

  const handleSearchTermChange = (event) => {
    dispatch(changeSearchTerm(event.target.value));
  }

  return (
  <div className="list-header">
    <h3 className="title is-3">Cars</h3>
    <div className="search fiel is-horizontal">
     <label className="label">Search</label>
     <input className="input" value={searchTerm} onChange={handleSearchTermChange}/>
    </div>
  </div>
  );
}
export default CarSearch;


Gennemgå og forstå koden (minder meget om de øvrige komponenter), afprøv at det virker som forventet.

 

 

Step 11 (CarValue.js)
I dette step skal vi implementere CarValue komponenten færdig.
CarValue komponenten har en "derived state" totalCost der kan beregnes ved at filtrerer bil arrayet data så det kun indeholder biler med et name hvor searchTerm indgår og så beregne den samlede sum af deres cost.

Her benyttes både array-helper metoderne filter og reduce, desuden benyttes "destructoring" af state da vi kun er interesseret i data og searchTerm ifm beregningen.


Erstat den eksisterende kode i CarValue.js med:

import { useSelector } from "react-redux";

function CarValue() {
  const totalCost = useSelector(({cars: {data, searchTerm}}) => //destructoring state to {cars: {data, searchTerm}} - kun interesseret i data og searchTerm
      data
         .filter((car) => car.name.toLowerCase().includes(searchTerm.toLowerCase()))
         .reduce((sum, car) => sum + car.cost, 0)
  );
  return <div className="car-value">Total Cost: {totalCost} kr.</div>;
}

export default CarValue;


Gennemgå koden og verificer at totalCost virker og bliver vist i Browseren:





Congratulation! - Yes Redux-Toolkit is still amazing!

/ Henrik H