DEV Community

Cover image for React Hooks Best Practices
Amin
Amin

Posted on • Updated on

React Hooks Best Practices

Avoid unecessary redefinition of functions

import { Fragment, useState } from "react";

export const Counter = () => {
  const [counter, setCounter] = useState(0);
  const [step, setStep] = useState(1);

  return (
    <Fragment>
      <div>
        <button onClick={() => setStep(step - 1)}>
          Decrement step
        </button>
        <span>
          {step}
        </span>
        <button onClick={() => setStep(step + 1)}>
          Increment step
        </button>
      </div>
      <div>
        <button onClick={() => setCounter(counter - step)}>
          Decrement counter
        </button>
        <span>
          {counter}
        </span>
        <button onClick={() => setCounter(counter + step)}>
          Increment counter
        </button>
      </div>
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

Everytime the button made to increment the step is clicked, the entire component will be rendered again.

In this component, we defined a bunch of functions that will be defined again.

For most of our functions, this is useless since, for instance, incrementing the step should not define the function attached to the button that is made to increment the step.

So we end up with some render that are costy, and some function redefinition could be avoided in this particular case.

import { Fragment, useState, useCallback } from "react";

export const Counter = () => {
  const [counter, setCounter] = useState(0);
  const [step, setStep] = useState(1);

  const decrementStep = useCallback(() => {
    setStep(step - 1);
  }, [step]);

  const incrementStep = useCallback(() => {
    setStep(step + 1);
  }, [step]);

  const incrementCounter = useCallback(() => {
    setCounter(counter + step);
  }, [step, counter]);

  const decrementCounter = useCallback(() => {
    setCounter(counter - step);
  }, [step, counter]);

  return (
    <Fragment>
      <div>
        <button onClick={decrementStep}>
          Decrement step
        </button>
        <span>
          {step}
        </span>
        <button onClick={incrementStep}>
          Increment step
        </button>
      </div>
      <div>
        <button onClick={decrementCounter}>
          Decrement counter
        </button>
        <span>
          {counter}
        </span>
        <button onClick={incrementCounter}>
          Increment counter
        </button>
      </div>
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

useCallback will register the function and won't return a new function if there is no need to. It will return a new function only if the dependency array (second argument) is changed.

So if we increment the step, the functions that have the step as dependency will also change on each render.

But if we increment the counter, only the function that have the counter as dependency will change, meaning every single one except the incrementStep and decrementStep.

We can even go further in this specific case and use the second form of setCounter to be dependent of less things and the setCounter to remove the dependencies completely.

import { Fragment, useState, useCallback } from "react";

export const Counter = () => {
  const [counter, setCounter] = useState(0);
  const [step, setStep] = useState(1);

  const decrementStep = useCallback(() => {
    setStep(previousStep => previousStep - 1);
  }, []);

  const incrementStep = useCallback(() => {
    setStep(previousStep => previousStep + 1);
  }, []);

  const incrementCounter = useCallback(() => {
    setCounter(previousCounter => previousCounter + step);
  }, [step]);

  const decrementCounter = useCallback(() => {
    setCounter(previousCounter => previousCounter + step);
  }, [step]);

  return (
    <Fragment>
      <div>
        <button onClick={decrementStep}>
          Decrement step
        </button>
        <span>
          {step}
        </span>
        <button onClick={incrementStep}>
          Increment step
        </button>
      </div>
      <div>
        <button onClick={decrementCounter}>
          Decrement counter
        </button>
        <span>
          {counter}
        </span>
        <button onClick={incrementCounter}>
          Increment counter
        </button>
      </div>
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now, if we increment the step using the incrementStep function, only the incrementCounter and decrementCounter functions will get redefined again since the incrementStep and decrementStep have no dependencies at all.

This is especially useful if we have lots of functions to define in our components, making this callback save some computation that would otherwise be unecessarily be made.

Avoid unecessary useState for complex state

If you depend on a complex object structure, you can use the useState hook in combination with the useCallback one in order to mutate the state accordingly.

import { Fragment, useState, useCallback } from "react";

export const User = () => {
  const [user, setUser] = useState({
    email: "email@domain.com",
    address: {
      country: "United States",
      location: {
        latitude: 1.2345
      }
    }
  });

  const updateUserAddressCountry = useCallback((event) => {
    setUser(previousUser => {
      return {
        ...previousUser,
        address: {
          ...previousUser.address,
          country: event.target.value
        }
      };
    });
  }, []);

  const updateEmail = useCallback((event) => {
    setUser(previousUser => {
      return {
        ...previousUser,
        email: event.target.value
      };
    });
  }, []);

  const updateUserAddressLocationLatitude = useCallback((event) => {
    setUser(previousUser => {
      return {
        ...previousUser,
        address: {
          ...previousUser.address,
          location: {
            ...previousUser.address.location,
            latitude: Number(event.target.value)
          }
        }
      };
    });
  }, []);

  return (
    <Fragment>
      <input
        type="text"
        value={user.address.country}
        onChange={updateUserAddressCountry} />
      <input
        type="email"
        value={user.email}
        onChange={updateEmail} />
      <input
        type="number"
        value={user.address.location.latitude}
        onChange={updateUserAddressLocationLatitude} />
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

The downside is that the component gets cluttered really fast and you'll end up with lots of functions with a lot in them.

Instead, you should use the useReducer hook to decrease the amount of code that you needed in your component to update this state.

import { Fragment, useCallback, useReducer } from "react";

const initialUserState = {
  email: "email@domain.com",
  address: {
    country: "United States",
    location: {
      latitude: 1.2345
    }
  }
};

const userReducer = (state, action) => {
  switch (action.type) {
    case "UPDATE_EMAIL":
      return {
        ...state,
        email: action.payload
      };

    case "UPDATE_COUNTRY":
      return {
        ...state,
        address: {
          ...state.address,
          country: action.payload
        }
      };

    case "UPDATE_LATITUDE":
      return {
        ...state,
        address: {
          ...state.address,
          location: {
            ...state.address.location,
            latitude: action.payload
          }
        }
      };

    default:
      return state
  }
};

export const User = () => {
  const [user, dispatch] = useReducer(userReducer, initialUserState);

  const updateUserAddressCountry = useCallback((event) => {
    dispatch({
      type: "UPDATE_COUNTRY",
      payload: event.target.value
    });
  }, []);

  const updateEmail = useCallback((event) => {
    dispatch({
      type: "UPDATE_EMAIL",
      payload: event.target.value
    });
  }, []);

  const updateUserAddressLocationLatitude = useCallback((event) => {
    dispatch({
      type: "UPDATE_LATITUDE",
      payload: Number(event.target.value)
    });
  }, []);

  return (
    <Fragment>
      <input
        type="text"
        value={user.address.country}
        onChange={updateUserAddressCountry} />
      <input
        type="email"
        value={user.email}
        onChange={updateEmail} />
      <input
        type="number"
        value={user.address.location.latitude}
        onChange={updateUserAddressLocationLatitude} />
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

This does not decrease the overall code (it can even increase) but the component gets uncluttered and you'll have a easier time separating the concerns since the initial state and the reducer don't have to live inside the component, whereas the hooks have to.

Avoid unecessary useState and useEffect for computed values

Sometimes, you have a state that depends on another one.

import { Fragment, useState, useEffect, useCallback } from "react";

const Price = () => {
  const [price, setPrice] = useState(12.34);
  const [taxPercentage, setTaxPercentage] = useState(20);
  const [total, setTotal] = useState(0);

  const updatePrice = useCallback((event) => {
    setPrice(Number(event.target.value));
  }, []);

  const updateTaxPercentage = useCallback((event) => {
    setTaxPercentage(Number(event.target.value));
  }, []);

  useEffect(() => {
    setTotal(price * (1 + (taxPercentage / 100)));
  }, [price, taxPercentage]);

  return (
    <Fragment>
      <input
        type="number"
        value={price}
        onChange={updatePrice} />
      <input
        type="number"
        value={taxPercentage}
        onChange={updateTaxPercentage} />
      <span>Total is {total}</span>
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Here, we can clearly see that the total depends on the price and the taxPercentage to be computed.

However, if we end up with lots of states that depends on another ones, we would end up with a lot of call to the useEffect hook, and the code might get messy.

Instead, we should use the useMemo hook.

import { Fragment, useState, useMemo, useCallback } from "react";

const Price = () => {
  const [price, setPrice] = useState(12.34);
  const [taxPercentage, setTaxPercentage] = useState(20);

  const total = useMemo(() => {
    return price * (1 + (taxPercentage / 100))
  }, [price, taxPercentage]);

  const updatePrice = useCallback((event) => {
    setPrice(Number(event.target.value));
  }, []);

  const updateTaxPercentage = useCallback((event) => {
    setTaxPercentage(Number(event.target.value));
  }, []);

  return (
    <Fragment>
      <input
        type="number"
        value={price}
        onChange={updatePrice} />
      <input
        type="number"
        value={taxPercentage}
        onChange={updateTaxPercentage} />
      <span>Total is {total}</span>
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

Since our setTotal setter was only ever used inside the useEffect hook, we can get rid of it and use instead a useMemo which will return a new state based on the derivation of another state like the price and taxPercentage that become the dependencies of the total to be computed.

This is very similar to what we saw with the useCallback hook and in fact, you could rewrite the useCallback hook yourself by using a useMemo hook.

import { useMemo } from "react";

const useCallback = (callback, dependencies) => {
  return useMemo(() => {
    return callback;
  }, dependencies);
};
Enter fullscreen mode Exit fullscreen mode

Avoid unecessary calls to the DOM Web API

Sometimes, all we need is a reference to an element to trigger some side-effect like a focus on that element whenever we need.

For that, we might want to use the DOM Web API with an id attribute in order to focus that element.

import { useState, useCallback } from "react";

const LoginForm = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const updateEmail = useCallback((event) => {
    setEmail(event.target.value);
  }, []);

  const updatePassword = useCallback((event) => {
    setPassword(event.target.value);
  }, []);

  const login = useCallback(() => {
    if (email.length === 0) {
      document.getElementById("email")?.focus();
      return;
    }

    if (password.length === 0) {
      document.getElementById("password")?.focus();
      return;
    }

    console.log("TODO: login using email & password");
  }, [email, password]);

  return (
    <form onSubmit={login}>
      <input
        id="email"
        type="email"
        value={email}
        onChange={updateEmail} />
      <input
        id="password"
        type="password"
        value={password}
        onChange={updatePassword} />
      <button type="submit">
        Login
      </button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

If you have a form component that should focus the field which has an error, for instance in our example an email that is empty, we could use the id attribute defined in the JSX we returned to fetch the element and focus that element only if we found it in the current DOM.

Whenever possible, we should try to avoid using the DOM Web API directly since React is made to help us manipulate the DOM without having to interact with it directly (whenever possible).

Instead, we should use the useRef hook to get a reference for a specific element.

import { useState, useCallback, useRef } from "react";

const LoginForm = () => {
  const [email, setEmail] = useState("");
  const emailRef = useRef(null);

  const [password, setPassword] = useState("");
  const passwordRef = useRef(null);

  const updateEmail = useCallback((event) => {
    setEmail(event.target.value);
  }, []);

  const updatePassword = useCallback((event) => {
    setPassword(event.target.value);
  }, []);

  const login = useCallback(() => {
    if (email.length === 0) {
      emailRef?.current?.focus();
      return;
    }

    if (password.length === 0) {
      passwordRef?.current?.focus(); 

      return;
    }

    console.log("TODO: login using email & password");
  }, [email, emailRef, password, passwordRef]);

  return (
    <form onSubmit={login}>
      <input
        ref={emailRef}
        type="email"
        value={email}
        onChange={updateEmail} />
      <input
        ref={passwordRef}
        type="password"
        value={password}
        onChange={updatePassword} />
      <button type="submit">
        Login
      </button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

Instead of depending on the id attribute, we can pass a reference to any JSX element that might be rendered as an HTMLElement in order to get its reference in the real DOM and focus it if we find it (it will be null until it gets mounted in the DOM).

This also let's us remove the id attribute which would only be useful to the DOM Web API in this case.

This also has the added benefit of preventing us from depending on the DOM Web API when creating our component, and will make it easier to test on the server for instance, instead of having to mock the DOM Web API for that specific case.

Avoid unreliable unique identifier generation methods

If you ever wanted to create a component that has a label and an input, you know that this is a best practice to link them both using an htmlFor and id attributes.

import { Fragment } from "react";

const LabelWithInput = ({ label, type, value, onChange }) => {
  return (
    <Fragment>
      <label htmlFor="input">
        {label}
      </label>
      <input
        id="input"
        type={type}
        value={value}
        onChange={onChange} />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

The downside of this component is that is can only ever be used once since the identifier here is hardcoded. If we try to use it twice, we would end up with two components with the same id attribute.

Instead, we could try to ask the user to provide the identifier.

import { Fragment } from "react";

const LabelWithInput = ({ id, label, type, value, onChange }) => {
  return (
    <Fragment>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        type={type}
        value={value}
        onChange={onChange} />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

This would work, since we would defer the work of creating the id attribute on the userland. But the user could make a mistake and copy/paste the component twice with the same identifier, leading us to square one.

import { Fragment, useState, useCallback } from "react";

const HomePage = () => {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");

  const updateEmail = useCallback((event) => {
    setEmail(event.target.value);
  }, []);

  const updatePassword = useCallback((event) => {
    setPassword(event.target.value);
  }, []);

  return (
    <Fragment>
      <LabelWithInput
        label="email"
        type="text"
        id="email"
        value={email}
        onChange={updateEmail} />
      <LabelWithInput
        label="password"
        type="password"
        id="email"
        value={password}
        onChange={updatePassword} />
    </Fragment>
  );
}
Enter fullscreen mode Exit fullscreen mode

Here, we made a mistake and copy & paste the component without changing the id attribute.

So we could instead make the id attribute generated randomly.

import { Fragment, useMemo } from "react";

const LabelWithInput = ({ label, type, value, onChange }) => {
  const id = useMemo(() => {
    return (Math.random() * 10e5).toString();
  }, []);

  return (
    <Fragment>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        type={type}
        value={value}
        onChange={onChange} />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

And this could work, but this is unreliable because we are relying on a randomly generated number that might return the same value twice since Math.random is not a secure random generator.

Instead, we should use the Crypto Web API which has primitives that would help us generate cryptographically secure pseudo-random generated values.

import { Fragment, useMemo } from "react";

const LabelWithInput = ({ label, type, value, onChange }) => {
  const id = useMemo(() => {
    return window.crypto.randomUUID();
  }, []);

  return (
    <Fragment>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        type={type}
        value={value}
        onChange={onChange} />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

So this method would totally work, but again, it would be hard to test a component that is using a Web API since it can be missing on the server-side when testing things out.

Note that as of recently, the Crypto API has been added to the Node.js API, but it should not be accessed using the window object.

So, as for the DOM Web API, we should use something that would help us generate an identifier without having to rely on any specific Web API.

For that we can use the useId hook.

import { Fragment, useMemo, useId } from "react";

const LabelWithInput = ({ label, type, value, onChange }) => {
  const id = useId();

  return (
    <Fragment>
      <label htmlFor={id}>
        {label}
      </label>
      <input
        id={id}
        type={type}
        value={value}
        onChange={onChange} />
    </Fragment>
  );
};
Enter fullscreen mode Exit fullscreen mode

That's it, whenever you need a unique identifier, use the useId hook since it can easily be used on the client-side or on the server-side, making it easy to use in test case scenario for instance instead of relying on a specific Web API for that.

Avoid overly complicated components

Components are pure functions, which means that they should, whenever possible, take inputs as parameters, and return values. They should not depend on anything else that is external to the component (or the function).

This is especially true for "Dumb Components". They are components that are often used as display components. For instance, a button that has a style that implements the Material Design can be a "Dumb Component" in the sense that it should not depend on anything external (this is merely a wrapper for an HTML Element).

Dumb components are useful because they limit the number of side-effects that is possible when calling this components, and are super easy to test.

import { useMemo } from "react";

const MaterialButton = ({ children }) => {
  const style = useMemo(() => ({
    backgroundColor: "purple",
    color: "white",
    borderRadius: "5px",
    padding: "10px 20px"
  }), []);

  return (
    <button style={style}>
      {children}
    </button>
  );
};
Enter fullscreen mode Exit fullscreen mode

But sometimes, you have to create components that have a reference to one or more state, like a in a form component for instance.

import { useState, useCallback, Fragment } from "react";

const ArticleForm = () => {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  const updateTitle = useCallback((event) => {
    setTitle(event.target.value);
  }, []);

  const updateBody = useCallback((event) => {
    setBody(event.target.value);
  }, []);

  const createArticle = useCallback(() => {
    setLoading(true);
    setError("");

    fetch(`https://api.website.com/articles`, {
      method: "POST",
      body: JSON.stringify({
        title,
        body
      })
    }).then(response => {
      if (!response.ok) {
        throw new Error("Bad response");
      }

      alert("Article created!");
    }).catch(error => {
      setError(String(error));
    }).finally(() => {
      setLoading(false);
    });
  }, [title, body]);

  if (loading) {
    return (
      <Fragment>
        <h1>Loading</h1>
        <p>Creating your article, please wait...</p>
      </Fragment>
    );
  }

  if (error) {
    return (
      <Fragment>
        <h1>Error</h1>
        <p>Oops! An error occurred while creating your article.</p>
        <small>{error}</small>
        <button onClick={createArticle}>Retry?</button>
      </Fragment>
    );
  }

  return (
    <form onSubmit={createArticle}>
      <input type="text" value={title} onChange={updateTitle} />
      <textarea value={body} onChange={updateBody} />
      <button type="submit">
        Create
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Even though this component is huge, it is really not that complicated.

We created a form component that allows any user to create a form. The main issue here is that there is too much inside this component.

We have the business logic of how to create the article and we have the user interface logic of how to display a form that allows the user to send a create article request.

We should thrive to create components that are close to dumb components whenever possible, and if our component has to have a state, we should try to split the business logic from the user interface logic.

For that, we can use a custom hook.

import { useState, useCallback, Fragment } from "react";

const useArticle = () => {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  const [error, setError] = useState("");
  const [loading, setLoading] = useState(false);

  const updateTitle = useCallback((event) => {
    setTitle(event.target.value);
  }, []);

  const updateBody = useCallback((event) => {
    setBody(event.target.value);
  }, []);

  const createArticle = useCallback(() => {
    setLoading(true);
    setError("");

    fetch(`https://api.website.com/articles`, {
      method: "POST",
      body: JSON.stringify({
        title,
        body
      })
    }).then(response => {
      if (!response.ok) {
        throw new Error("Bad response");
      }

      alert("Article created!");
    }).catch(error => {
      setError(String(error));
    }).finally(() => {
      setLoading(false);
    });
  }, [title, body]);

  return {
    title,
    body,
    error,
    loading,
    updateTitle,
    updateBody,
    createArticle
  };
};

const ArticleForm = () => {
  const {
    title,
    body,
    error,
    loading,
    updateTitle,
    updateBody,
    createArticle
  } = useArticle();

  if (loading) {
    return (
      <Fragment>
        <h1>Loading</h1>
        <p>Creating your article, please wait...</p>
      </Fragment>
    );
  }

  if (error) {
    return (
      <Fragment>
        <h1>Error</h1>
        <p>Oops! An error occurred while creating your article.</p>
        <small>{error}</small>
        <button onClick={createArticle}>Retry?</button>
      </Fragment>
    );
  }

  return (
    <form onSubmit={createArticle}>
      <input type="text" value={title} onChange={updateTitle} />
      <textarea value={body} onChange={updateBody} />
      <button type="submit">
        Create
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

As you can see, our component is now focused on displaying the informations that are coming from our hook.

The custom hook we created (useArticle) is now responsible for all of the business logic and don't handle the creation and the dipslay of all of the informations that we need in order to display and send a create article request.

This means a better separation of the concerns of our component, and also a easier time testing it since we can now test that our business logic is according to the specifications, without having to deal with the user interface in our tests.

Avoid forgetting the cleanup function when using the useEffect hook

Sometimes, we don't have the choice of not using a specific Web API and we might have to listen for events in this context.

import { useState, useEffect } from "react";

const MouseTracker = () => {
  const [abscissa, setAbscissa] = useState(0);
  const [ordinate, setOrdinate] = useState(0);

  useEffect(() => {
    window.addEventListener("mousemove", (event) => {
      setAbscissa(event.clientX);
      setOrdinate(event.clientY);
    });
  }, []);

  return (
    <p>Mouse is at ({abscissa}, {ordinate})</p>
  );
};
Enter fullscreen mode Exit fullscreen mode

In this example, we want to track our mouse position inside the window so we listen for the mousemove event from the window object.

This works great, until our component is unmounted, for instance if we switch page.

If we try going back, we might not see a difference, but our code will subscribe to the mousemove event twice. And each time we try to switch page and go back, a new subscription for that event will be made.

This is bad because we don't need 15 subscriptions at once to trigger, this might lead to memory leaks and performance issues.

Instead, we should be using the cleanup function that allows us to perform any cleanup actions inside our useEffect hook.

import { useState, useEffect, useCallback } from "react";

const MouseTracker = () => {
  const [abscissa, setAbscissa] = useState(0);
  const [ordinate, setOrdinate] = useState(0);

  const onMouseMove = useCallback((event) => {
    setAbscissa(event.clientX);
    setOrdinate(event.clientY);
  }, []);

  useEffect(() => {
    window.addEventListener("mousemove", onMouseMove);

    return () => {
      window.removeEventListener("mousemove", onMouseMove);
    };
  }, []);

  return (
    <p>Mouse is at ({abscissa}, {ordinate})</p>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now, each time we need to switch page, our component will get unmounted from the DOM, and the cleanup function will be triggered, removing the listener as expected.

This time, if we try to go back to the page where the MouseTracker component exist, it will only subscribe to one event at a time, and perform our action accordingly, without memory or performance issues.

This is also true for components that first need to initiate a request to an API in order to fetch some informations. If you switch component before the request has been fulfilled, you'll end up with a request that is sent when the component is not needed anymore.

For that, you can use an AbortController in order to cancel the request in the useEffect's cleanup function.

import { useEffect, useMemo, useState } from "react";

const UsersPage = () => {
  const [users, setUsers] = useState();

  const abortController = useMemo(() => {
    return new AbortController();
  }, []);

  useEffect(() => {
    fetch("https://placeholder.typicode.com/users", {
      signal: abortController.signal
    }).then(response => {
      if (!response.ok) {
        return Promise.reject(new Error("Bad response"));
      }

      return response.json();
    }).then(users => {
      setUsers(users);
    }).catch(error => {
      console.error(error);
    });

    return () => {
      abortController.abort();
    };
  }, []);

  return (
    <p>TODO: display the users</p>
  );
};
Enter fullscreen mode Exit fullscreen mode

In this case, the component above will instantiate a new AbortController instance and attach it to the request that is being made when the component is mounted.

And if ever the request is sent too late before the component gets unmounted, the useEffect will take care of calling the cleaup function where we aborted the reqeust, making this component bandwidth efficient.

Top comments (2)

Collapse
 
8koi profile image
Hachikoi

Exactly what I was looking for to improve my react game! tnx

Collapse
 
aminnairi profile image
Amin

Glad you liked it, thanks for taking the time to give your feedback, it means a lot!