כיצד לבנות אפליקציית פוקימון אינטרנט פשוטה עם React Hooks ו- API של ההקשר

אחרי שבע שנים של פיתוח מלא של מחסניות באמצעות JavaScript, רובי, פייתון, וניל, בימים אלה אני עובד בעיקר עם JavaScript, Typescript, React ו- Redux.

קהילת JavaScript נהדרת ועוברת ממש מהר. טונות של דברים נוצרות "בן לילה", בדרך כלל באופן פיגורטיבי, אך לפעמים פשוטו כמשמעו. כל זה מקשה באמת להתעדכן.

אני תמיד מרגיש שאני מאחר למסיבת JavaScript. ואני רוצה להיות שם, למרות שאני לא ממש אוהב מסיבות.

רק שנה אחת של עבודה עם React ו- Redux והרגשתי שאני צריך ללמוד דברים חדשים כמו Hooks ו- Context API כדי לנהל את המדינה. לאחר שקראתי כמה מאמרים בנושא רציתי לנסות את המושגים האלה, אז יצרתי פרויקט פשוט כמעבדה להתנסות בדברים האלה.

מאז שהייתי ילד קטן התלהבתי מפוקימון. תמיד היה כיף לשחק את המשחקים ב- Game Boy ולכבוש את כל הליגות. עכשיו כמפתח, אני רוצה לשחק עם ה- API של פוקימון.

החלטתי לבנות דף אינטרנט פשוט שבו אוכל לשתף נתונים בין חלקי הדף השונים. בדף יהיו שלושה חלקים עיקריים:

  • קופסה עם רשימה של כל הפוקימונים הקיימים
  • קופסה עם רשימה של כל הפוקימונים שנתפסו
  • תיבה עם קלט להוספת פוקימונים חדשים לרשימה

ולכל תיבה תהיה ההתנהגות או הפעולות הבאות:

  • עבור כל פוקימונים בתיבה הראשונה, אני יכול ללכוד אותם ולשלוח לתיבה השנייה
  • עבור כל פוקימונים בתיבה השנייה, אני יכול לשחרר אותם ולשלוח לתיבה הראשונה
  • כאלוהי משחק, אני מסוגל ליצור פוקימונים על ידי מילוי הקלט ושליחתם לתיבה הראשונה

אז כל התכונות שרציתי ליישם היו ברורות - רשימות ופעולות.

רישום פוקימונים

התכונה הבסיסית שרציתי לבנות קודם הייתה רישום פוקימונים. אז עבור מגוון של אובייקטים, רציתי לרשום ולהראות את nameהתכונה של כל אובייקט.

התחלתי עם התיבה הראשונה: הפוקימון הקיים.

בהתחלה חשבתי שאני לא צריך את ה- API של פוקימון - אני יכול פשוט ללעוג לרשימה ולראות אם זה עובד. עם useState, אני יכול להכריז על מצב הרכיב שלי ולהשתמש בו.

אנו מגדירים את זה עם ערך ברירת מחדל של רשימת פוקימונים מדומה, רק כדי לבדוק את זה:

const [pokemons] = useState([ { id: 1, name: 'Bulbasaur' }, { id: 2, name: 'Charmander' }, { id: 3, name: 'Squirtle' } ]); 

הנה לנו רשימה של שלושה חפצי פוקימון. useStateהוו מספק זוג פריטים: המצב הנוכחי ואת פונקציה לתת לך לעדכן מדינה יצרה זה.

עכשיו עם מצבו של הפוקימון, אנו יכולים למפות אותו ולהעביר את שמו של כל אחד מהם.

{pokemons.map((pokemon) =>

{pokemon.name}

)}

זו רק מפה המחזירה את שמו של כל פוקימון בתג פיסקה.

זה כל הרכיב המיושם:

import React, { useState } from 'react'; const PokemonsList = () => { const [pokemons] = useState([ { id: 1, name: 'Bulbasaur' }, { id: 2, name: 'Charmander' }, { id: 3, name: 'Squirtle' } ]); return ( 

Pokemons List

{pokemons.map((pokemon) =>

{pokemon.id}

{pokemon.name}

)} ) } export default PokemonsList;

רק קצת לצבוט כאן:

  • הוספתי את keyהשילוב בשילוב של הפוקימונים idושלname
  • והעברתי גם פיסקה idלתכונה (רק בדקתי אותה. אבל נסיר אותה בהמשך).

גדול! עכשיו יש לנו את הרשימה הראשונה.

אני רוצה לבצע את אותה יישום אבל עכשיו עבור הפוקימונים שנתפסו. אבל עבור הפוקימונים שנתפסו, אני קודם כל רוצה ליצור רשימה ריקה מכיוון שכאשר "המשחק" יתחיל, לא יהיה לי שום פוקימון שנתפס, נכון? ימין!

const [pokemons] = useState([]); 

זהו, ממש פשוט!

כל הרכיב נראה דומה לזה:

import React, { useState } from 'react'; const CapturedPokemons = () => { const [pokemons] = useState([]); return ( 

Captured Pokemons

{pokemons.map((pokemon) =>

{pokemon.id}

{pokemon.name}

)} ) } export default CapturedPokemons;

כאן אנו משתמשים map, אך מכיוון שהמערך ריק, הוא אינו מעבד דבר.

עכשיו שיש לי את שני המרכיבים העיקריים, אני יכול להשתמש בהם יחד Appברכיב:

import React from 'react'; import './App.css'; import PokemonsList from './PokemonsList'; import Pokedex from './Pokedex'; const App = () => ( ); export default App; 

לכידה ושחרור

זהו החלק השני של האפליקציה שלנו שבו אנו יכולים ללכוד ולשחרר פוקימונים. אז בואו נעבור על ההתנהגות הצפויה.

עבור כל פוקימונים ברשימת הפוקימונים הזמינים, אני רוצה לאפשר פעולה ללכידתם. פעולת הלכידה תסיר אותם מהרשימה בה הם היו ותוסיף אותם לרשימת הפוקימונים שנתפסו.

לפעולת השחרור תהיה התנהגות דומה. אבל במקום לעבור מהרשימה הזמינה לרשימה שנתפסה, זה יהיה הפוך. נעביר אותם מהרשימה שנתפסה לרשימה הזמינה.

אז שתי התיבות צריכות לשתף נתונים כדי להיות מסוגלים להוסיף פוקימונים לרשימה השנייה. איך נעשה זאת מכיוון שהם רכיבים שונים באפליקציה? בואו נדבר על ה- React Context API.

ממשק ה- API של Context תוכנן ליצור נתונים גלובליים עבור עץ מוגדר של רכיבי React. מכיוון שהנתונים הם גלובליים, אנו יכולים לחלוק אותם בין רכיבים בעץ מוגדר זה. אז בואו נשתמש בו כדי לשתף את נתוני הפוקימון הפשוטים שלנו בין שתי התיבות.

הערה נפשית: "נעשה שימוש בעיקר בהקשר כאשר חלק מהנתונים צריכים להיות נגישים על ידי רכיבים רבים ברמות קינון שונות." תגיב מסמכים.

באמצעות ה- API, אנו פשוט יוצרים הקשר חדש כזה:

import { createContext } from 'react'; const PokemonContext = createContext(); 

Now, with the PokemonContext, we can use its provider. It will work as a component wrapper of a tree of components. It provides global data to these components and enables them to subscribe to any changes related to this context. It looks like this:

The value prop is just a value that this context provides the wrapped components. What should we provide to the available and the captured lists?

  • pokemons: to list in the available list
  • capturedPokemons: to list in the captured list
  • setPokemons: to be able to update the available list
  • setCapturedPokemons: to be able to update the captured list

As I mentioned before in the useState part, this hook always provides a pair: the state and a function to update this state. This function handles and updates the context state. In other words, they are the setPokemons and setCapturedPokemons. How?

const [pokemons, setPokemons] = useState([ { id: 1, name: 'Bulbasaur' }, { id: 2, name: 'Charmander' }, { id: 3, name: 'Squirtle' } ]); 

Now we have the setPokemons.

const [capturedPokemons, setCapturedPokemons] = useState([]); 

And now we also have the setCapturedPokemons.

With all these values in hand, we can now pass them to the provider's value prop.

import React, { createContext, useState } from 'react'; export const PokemonContext = createContext(); export const PokemonProvider = (props) => { const [pokemons, setPokemons] = useState([ { id: 1, name: 'Bulbasaur' }, { id: 2, name: 'Charmander' }, { id: 3, name: 'Squirtle' } ]); const [capturedPokemons, setCapturedPokemons] = useState([]); const providerValue = { pokemons, setPokemons, capturedPokemons, setCapturedPokemons }; return (  {props.children}  ) }; 

I created a PokemonProvider to wrap all this data and the APIs to create the context and return the context provider with the defined value.

But how do we provide all this data and APIs to the component? We need to do two main things:

  • Wrap the components into this context provider
  • Use the context in each component

Let's wrap them first:

const App = () => ( ); 

And we use the context by using the useContext and passing the created PokemonContext. Like this:

import { useContext } from 'react'; import { PokemonContext } from './PokemonContext'; useContext(PokemonContext); // returns the context provider value we created 

We want to be able to catch the available pokémon, so it would be useful to have the setCapturedPokemons function API update the captured pokémon.

As each pokémon is captured, we need to remove it from the available list. setPokemons is also needed here. And to update each list, we need the current data. So basically we need everything from the context provider.

We need to build a button with an action to capture the pokémon:

  • tag with an onClick calling the capture function and passing the pokémon
+ 
  • The capture function will update the pokemons and the capturedPokemons lists
const capture = (pokemon) => (event) => { // update captured pokemons list // update available pokemons list }; 

To update the capturedPokemons, we can just call the setCapturedPokemons function with the current capturedPokemons and the pokémon to be captured.

setCapturedPokemons([...capturedPokemons, pokemon]); 

And to update the pokemons list, just filter the pokémon that will be captured.

setPokemons(removePokemonFromList(pokemon)); 

removePokemonFromList is just a simple function to filter the pokémon by removing the captured pokémon.

const removePokemonFromList = (removedPokemon) => pokemons.filter((pokemon) => pokemon !== removedPokemon) 

How does the component look now?

import React, { useContext } from 'react'; import { PokemonContext } from './PokemonContext'; export const PokemonsList = () => { const { pokemons, setPokemons, capturedPokemons, setCapturedPokemons } = useContext(PokemonContext); const removePokemonFromList = (removedPokemon) => pokemons.filter(pokemon => pokemon !== removedPokemon); const capture = (pokemon) => () => { setCapturedPokemons([...capturedPokemons, pokemon]); setPokemons(removePokemonFromList(pokemon)); }; return ( 

Pokemons List

{pokemons.map((pokemon) => {pokemon.name} + )} ); }; export default PokemonsList;

It will look very similar to the captured pokémon component. Instead of capture, it will be a release function:

import React, { useContext } from 'react'; import { PokemonContext } from './PokemonContext'; const CapturedPokemons = () => { const { pokemons, setPokemons, capturedPokemons, setCapturedPokemons, } = useContext(PokemonContext); const releasePokemon = (releasedPokemon) => capturedPokemons.filter((pokemon) => pokemon !== releasedPokemon); const release = (pokemon) => () => { setCapturedPokemons(releasePokemon(pokemon)); setPokemons([...pokemons, pokemon]); }; return ( 

CapturedPokemons

{capturedPokemons.map((pokemon) => {pokemon.name} - )} ); }; export default CapturedPokemons;

Reducing complexity

Now we use the useState hook, the Context API, and the context provider useContext. And more importantly, we can share data between pokémon boxes.

Another way to manage the state is by using useReducer as an alternative to useState.

The reducer lifecycle works like this: useReducer provides a dispatch function. With this function, we can dispatch an action inside a component. The reducer receives the action and the state. It understands the type of action, handles the data, and return a new state. Now, the new state can be used in the component.

As an exercise and to have a better understanding of this hook, I tried to replace useState with it.

useState was inside the PokemonProvider. We can redefine the initial state for the available and the captured pokémon in this data structure:

const defaultState = { pokemons: [ { id: 1, name: 'Bulbasaur' }, { id: 2, name: 'Charmander' }, { id: 3, name: 'Squirtle' } ], capturedPokemons: [] }; 

And pass this value to useReducer:

const [state, dispatch] = useReducer(pokemonReducer, defaultState); 

useReducer receives two parameters: the reducer and the initial state. Let's build the pokemonReducer now.

The reducer receives the current state and the action that was dispatched.

const pokemonReducer = (state, action) => // returns the new state based on the action type 

Here we get the action type and return a new state. The action is an object. It looks like this:

{ type: 'AN_ACTION_TYPE' } 

But could also be bigger:

{ type: 'AN_ACTION_TYPE', pokemon: { name: 'Pikachu' } } 

In this case, we'll pass a pokémon to the action object. Let's pause for a minute and think about what we want to do inside the reducer.

Here, we usually update data and handle actions. Actions are dispatched, so actions are behavior. And the behaviors from our app are capture and release! These are the actions we need to handle here.

This is what our reducer will look like:

const pokemonReducer = (state, action) => { switch (action.type) { case 'CAPTURE': // handle capture and return new state case 'RELEASE': // handle release and return new state default: return state; } }; 

If our action type is CAPTURE, we handle it in one way. If our action type is RELEASE, we handle it another way. If the action type doesn't match any of these types, just return the current state.

When we capture the pokémon, we need to update both lists: remove the pokémon from the available list and add it to the captured list. This state is what we need to return from the reducer.

const getPokemonsList = (pokemons, capturedPokemon) => pokemons.filter(pokemon => pokemon !== capturedPokemon) const capturePokemon = (pokemon, state) => ({ pokemons: getPokemonsList(state.pokemons, pokemon), capturedPokemons: [...state.capturedPokemons, pokemon] }); 

The capturePokemon function just returns the updated lists. The getPokemonsList removes the captured pokémon from the available list.

And we use this new function in the reducer:

const pokemonReducer = (state, action) => { switch (action.type) { case 'CAPTURE': return capturePokemon(action.pokemon, state); case 'RELEASE': // handle release and return new state default: return state; } }; 

Now the release function!

const getCapturedPokemons = (capturedPokemons, releasedPokemon) => capturedPokemons.filter(pokemon => pokemon !== releasedPokemon) const releasePokemon = (releasedPokemon, state) => ({ pokemons: [...state.pokemons, releasedPokemon], capturedPokemons: getCapturedPokemons(state.capturedPokemons, releasedPokemon) }); 

The getCapturedPokemons remove the released pokémon from the captured list. The releasePokemon function returns the updated lists.

Our reducer looks like this now:

const pokemonReducer = (state, action) => { switch (action.type) { case 'CAPTURE': return capturePokemon(action.pokemon, state); case 'RELEASE': return releasePokemon(action.pokemon, state); default: return state; } }; 

Just one minor refactor: action types! These are strings and we can extract them into a constant and provide for the dispatcher.

export const CAPTURE = 'CAPTURE'; export const RELEASE = 'RELEASE'; 

And the reducer:

const pokemonReducer = (state, action) => { switch (action.type) { case CAPTURE: return capturePokemon(action.pokemon, state); case RELEASE: return releasePokemon(action.pokemon, state); default: return state; } }; 

The entire reducer file looks like this:

export const CAPTURE = 'CAPTURE'; export const RELEASE = 'RELEASE'; const getCapturedPokemons = (capturedPokemons, releasedPokemon) => capturedPokemons.filter(pokemon => pokemon !== releasedPokemon) const releasePokemon = (releasedPokemon, state) => ({ pokemons: [...state.pokemons, releasedPokemon], capturedPokemons: getCapturedPokemons(state.capturedPokemons, releasedPokemon) }); const getPokemonsList = (pokemons, capturedPokemon) => pokemons.filter(pokemon => pokemon !== capturedPokemon) const capturePokemon = (pokemon, state) => ({ pokemons: getPokemonsList(state.pokemons, pokemon), capturedPokemons: [...state.capturedPokemons, pokemon] }); export const pokemonReducer = (state, action) => { switch (action.type) { case CAPTURE: return capturePokemon(action.pokemon, state); case RELEASE: return releasePokemon(action.pokemon, state); default: return state; } }; 

As the reducer is now implemented, we can import it into our provider and use it in the useReducer hook.

const [state, dispatch] = useReducer(pokemonReducer, defaultState); 

As we are inside the PokemonProvider, we want to provide some value to the consuming components: the capture and release actions.

These functions just need to dispatch the correct action type and pass the pokémon to the reducer.

  • The capture function: it receives the pokémon and returns a new function that dispatches an action with the type CAPTURE and the captured pokémon.
const capture = (pokemon) => () => { dispatch({ type: CAPTURE, pokemon }); }; 
  • The release function: it receives the pokémon and returns a new function that dispatches an action with the type RELEASE and the released pokémon.
const release = (pokemon) => () => { dispatch({ type: RELEASE, pokemon }); }; 

Now with the state and the actions implemented, we can provide these values to the consuming components. Just update the provider value prop.

const { pokemons, capturedPokemons } = state; const providerValue = { pokemons, capturedPokemons, release, capture };  {props.children}  

Great! Now back to the component. Let's use these new actions. All the capture and release logics are encapsulated in our provider and reducer. Our component is pretty clean now. The useContext will look like this:

const { pokemons, capture } = useContext(PokemonContext); 

And the whole component:

import React, { useContext } from 'react'; import { PokemonContext } from './PokemonContext'; const PokemonsList = () => { const { pokemons, capture } = useContext(PokemonContext); return ( 

Pokemons List

{pokemons.map((pokemon) => {pokemon.name} + )} ) }; export default PokemonsList;

For the captured pokémon component, it will look the very similar to the useContext:

const { capturedPokemons, release } = useContext(PokemonContext); 

And the whole component:

import React, { useContext } from 'react'; import { PokemonContext } from './PokemonContext'; const Pokedex = () => { const { capturedPokemons, release } = useContext(PokemonContext); return ( 

Pokedex

{capturedPokemons.map((pokemon) => {pokemon.name} - )} ) }; export default Pokedex;

No logic. Just UI. Very clean.

Pokémon God – The Creator

Now that we have the communication between the two lists, I want to build a third box. This will how we create new pokémon. But it is just a simple input and submit button.

When we add a pokémon's name into the input and press the button, it will dispatch an action to add this pokémon to the available list.

As we need to access the available list to update it, we need to share the state. So our component will be wrapped by our PokemonProvider together with the other components.

const App = () => ( ); 

Let's build the PokemonForm component now. The form is pretty straightforward:

We have a form, an input, and a button. To sum up, we also have a function to handle the form submit and another function to handle the input on change.

The handleNameOnChange will be called every time the user types or removes a character. I wanted to build a local state, a representation of the pokemon name. With this state, we can use it to dispatch when submitting the form.

As we want to try hooks, we will use useState to handle this local state.

const [pokemonName, setPokemonName] = useState(); const handleNameOnChange = (e) => setPokemonName(e.target.value); 

We use the setPokemonName to update the pokemonName every time the user interacts with the input.

And the handleFormSubmit is a function to dispatch the new pokémon to be added to the available list.

const handleFormSubmit = (e) => { e.preventDefault(); addPokemon({ id: generateID(), name: pokemonName }); }; 

addPokemon is the API we will build later. It receives the pokémon's id and name. The name is the local state we defined, pokemonName.

generateID is just a simple function I built to generate a random number. It looks like this:

export const generateID = () => { const a = Math .random() .toString(36) .substring(2, 15) const b = Math .random() .toString(36) .substring(2, 15) return a + b; }; 

addPokemon will be provided by the context API we build. That way, this function can receive the new pokémon and add to the available list. It looks like this:

const addPokemon = (pokemon) => { dispatch({ type: ADD_POKEMON, pokemon }); }; 

It will dispatch this action type ADD_POKEMON and also pass the pokémon.

In our reducer, we add the case for the ADD_POKEMON and handle the state to add the new pokémon to state.

const pokemonReducer = (state, action) => { switch (action.type) { case CAPTURE: return capturePokemon(action.pokemon, state); case RELEASE: return releasePokemon(action.pokemon, state); case ADD_POKEMON: return addPokemon(action.pokemon, state); default: return state; } }; 

And the addPokemon function will be:

const addPokemon = (pokemon, state) => ({ pokemons: [...state.pokemons, pokemon], capturedPokemons: state.capturedPokemons }); 

Another approach is to destructure the state and change only the pokémon's attribute, like this:

const addPokemon = (pokemon, state) => ({ ...state, pokemons: [...state.pokemons, pokemon], }); 

Back to our component, we just need to make sure the useContext provides the addPokemon dispatch API based on the PokemonContext:

const { addPokemon } = useContext(PokemonContext); 

And the whole component looks like this:

import React, { useContext, useState } from 'react'; import { PokemonContext } from './PokemonContext'; import { generateID } from './utils'; const PokemonForm = () => { const [pokemonName, setPokemonName] = useState(); const { addPokemon } = useContext(PokemonContext); const handleNameOnChange = (e) => setPokemonName(e.target.value); const handleFormSubmit = (e) => { e.preventDefault(); addPokemon({ id: generateID(), name: pokemonName }); }; return (     ); }; export default PokemonForm; 

Now we have the available pokémon list, the captured pokémon list, and the third box to create new pokémon.

Pokémon Effects

Now that we have our app almost complete, we can replace the mocked pokémon list with a list of pokémon from the PokéAPI.

So, inside the function component, we can't do any side effects like logging or subscriptions. This is why the useEffect hook exists. With this hook, we can fetch pokémon (a side-effect), and add to the list.

Fetching from the PokéAPI looks like this:

const url = "//pokeapi.co/api/v2/pokemon"; const response = await fetch(url); const data = await response.json(); data.results; // [{ name: 'bulbasaur', url: '//pokeapi.co/api/v2/pokemon/1/' }, ...] 

The results attribute is the list of fetched pokémon. With this data, we will be able to add them to the pokémon list.

Let's get the request code inside useEffect:

useEffect(() => { const fetchPokemons = async () => { const response = await fetch(url); const data = await response.json(); data.results; // update the pokemons list with this data }; fetchPokemons(); }, []); 

To be able to use async-await, we need to create a function and call it later. The empty array is a parameter to make sure useEffect knows the dependencies it will look up to re-run.

The default behavior is to run the effect of every completed render. If we add a dependency to this list, useEffect will only re-run when the dependency changes, instead of running in all completed renders.

Now that we've fetched the pokémon, we need to update the list. It's an action, a new behavior. We need to use the dispatch again, implement a new type in the reducer, and update the state in the context provider.

In PokemonContext, we created the addPokemons function to provide an API to the consuming component using it.

const addPokemons = (pokemons) => { dispatch({ type: ADD_POKEMONS, pokemons }); }; 

It receives pokémon and dispatches a new action: ADD_POKEMONS.

In the reducer, we add this new type, expect the pokémon, and call a function to add the pokémon to the available list state.

const pokemonReducer = (state, action) => { switch (action.type) { case CAPTURE: return capturePokemon(action.pokemon, state); case RELEASE: return releasePokemon(action.pokemon, state); case ADD_POKEMON: return addPokemon(action.pokemon, state); case ADD_POKEMONS: return addPokemons(action.pokemons, state); default: return state; } }; 

The addPokemons function just adds the pokémon to the list:

const addPokemons = (pokemons, state) => ({ pokemons: pokemons, capturedPokemons: state.capturedPokemons }); 

We can refactor this by using state destructuring and the object property value shorthand:

const addPokemons = (pokemons, state) => ({ ...state, pokemons, }); 

As we provide this function API to the consuming component now, we can use the useContext to get it.

const { addPokemons } = useContext(PokemonContext); 

The whole component looks like this:

import React, { useContext, useEffect } from 'react'; import { PokemonContext } from './PokemonContext'; const url = "//pokeapi.co/api/v2/pokemon"; export const PokemonsList = () => { const { state, capture, addPokemons } = useContext(PokemonContext); useEffect(() => { const fetchPokemons = async () => { const response = await fetch(url); const data = await response.json(); addPokemons(data.results); }; fetchPokemons(); }, [addPokemons]); return ( 

Pokemons List

{state.pokemons.map((pokemon) => {pokemon.name} + )} ); }; export default PokemonsList;

Wrapping up

This was my attempt to share what I learned while trying to use hooks in a mini side project.

We learned how to handle local state with useState, building a global state with the Context API, how to rewrite and replace useState with useReducer, and how to do side-effects within useEffect.

Disclaimer: this was just an experimental project for learning purposes. I may not have used best practices for hooks or made them scalable for big projects.

I hope this was good reading! Keep learning and coding!

You can other articles like this on my blog.

My Twitter and Github.

Resources

  • React Docs: Context
  • React Docs: Hooks
  • Pokemon Hooks side-project: source code
  • Learn React by building an App