How to immediately rerender child component after updating the sessionStorage using custom hook

Viewed 222

My goal is to build a simple product review system using React, Next.JS and the browser's sessionStorage. The user should be able to click on a button to "Add a review". This action will trigger the display of a text area and a submit button. Once the user click the submit button, the review content should be persisted in the sessionStorage and immediately showed up in a list of reviews.

submit review prototype

My problem is that although I can update the sessionStorage after submitting the review, the app is not displaying the list of existing reviews right away. If I leave the page and get back, the reviews will be shown up, meaning my custom hook seems to be working fine.

Here's the ReviewForm.tsx code:

export const ReviewForm: React.FC<Props> = ({ productId }): JSX.Element => {
  const [showForm, setShowForm] = useState<boolean>(false);
  const [storedValues, setStoredValues] = useSessionStorage<SessionStorage[]>(
    "products-reviews",
    []
  );

  const registerReview = (event: any) => {
    event.preventDefault();
    const reviewText = event.target.review.value;
    const productIndex = storedValues?.findIndex(
      (review) => review.productId === productId
    );

    if (productIndex === -1 || productIndex === undefined) {
      setStoredValues([...storedValues!, { productId, reviews: [reviewText] }]);
    } else {
      const reviews = [...storedValues![productIndex].reviews, reviewText];
      const updatedReviews = [...storedValues!];
      updatedReviews[productIndex].reviews = reviews;
      setStoredValues(updatedReviews);
    }

    setShowForm(false);
  };

  return (
    <div className={styles.reviewsContainer}>
      <button
        className={styles.addReviewButton}
        onClick={() => setShowForm(true)}
      >
        <span>Add a review</span>
      </button>
      {showForm && (
        <form
          className={styles.reviewForm}
          onSubmit={(event) => registerReview(event)}
        >
          <textarea className={styles.reviewInput} name="review" required />
          <button className={styles.reviewSubmitButton} type="submit">
            Submit
          </button>
        </form>
      )}
      <ReviewList productId={productId} />
    </div>
  );
};

And here's the ReviewList.tsx component, rendered inside ReviewForm.tsx:

export const ReviewList: React.FC<Props> = ({ productId }): JSX.Element => {
  const [reviews, _] = useSessionStorage<SessionStorage[]>(
    "products-reviews",
    []
  );

  const productReviews = reviews?.find(
    (review) => review.productId === productId
  )?.reviews;

  return (
    <ul>
      {productReviews?.map((review) => (
        <li key={Math.random() * 10000}>{review}</li>
      ))}
    </ul>
  );
};

Lastly, here's my custom hook useSessionStorage:

export const useSessionStorage = <T>(
  key: string,
  initialValue?: T
): SessionStorage<T> => {
  const [storedValue, setStoredValue] = useState<T | undefined>(() => {
    if (!initialValue) return;
    try {
      const value = sessionStorage.getItem(key);
      return value ? JSON.parse(value) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  useEffect(() => {
    if (storedValue) {
      try {
        sessionStorage.setItem(key, JSON.stringify(storedValue));
      } catch (error) {
        console.log(error);
      }
    }
  }, [storedValue, key]);

  return [storedValue, setStoredValue];
};

The title of my question says "how to rerender child component" because I noticed if I completely delete the ReviewList.tsx component, bringing all its render logic inside the ReviewForm.tsx, my application will behave as expected.

So maybe the problem is related with this relation between components?

Any advice is welcome.

1 Answers

The problem

The problem is in your useSessionStorage hook. It is not actually synchronized with the session storage, because the state is actually stored with useState, it is only populated on mount.

How does it work in your case:

  1. You initialize FIRST STATE using useState (inside custom useSessionStorage hook) with current session storage value on component mount at ReviewList.tsx
  2. You initialize SECOND STATE using useState (inside custom useSessionStorage hook) with current session storage value on component mount at ReviewForm.tsx
  3. You mutate SECOND STATE and push the changes to session storage with useEffect

So FIRST STATE is not updated with the new value until you re-mount the component.

Solution 1 (Will work only for sync between different browser tabs)

We need to reverse the flow of data from useState -> sessionStorage to sessionStorage -> useState

export const useSessionStorage = <T>(
  key: string,
  initialValue?: T
): SessionStorage<T> => {
  const [storedValue, setStoredValue] = useState<T | undefined>(() => {
    if (!initialValue) return;
    try {
      const value = sessionStorage.getItem(key);
      return value ? JSON.parse(value!) : initialValue;
    } catch (error) {
      return initialValue;
    }
  });

  const setStorageValue = useCallback((newValue: T) => {
    try {
      sessionStorage.setItem(key, JSON.stringify(newValue));
    } catch (error) {
      console.log(error);
    }
  }, []);

  /** This `useEffect` will make sure `storedValue` is always in sync with the `sessionStorage` */
  useEffect(() => {
    const listenToStorageEvent = (event: StorageEvent) => {
      if (event.storageArea === sessionStorage && event.key === key) {
        try {
          const newValue = JSON.parse(event.newValue!);

          if (storedValue !== newValue) {
            setStoredValue(newValue);
          }
        } catch (error) {
          console.log(error);
        }
      }
    };

    window.addEventListener("storage", listenToStorageEvent);

    return () => {
      window.removeEventListener("storage", listenToStorageEvent);
    };
  }, [key]);

  // We expose `setStorageValue` which works with `sessionStorage` instead of `setStoredValue` which works with local state
  return [storedValue, setStorageValue];
};

Solution 2

Use custom events to be able to sync the same tab too https://github.com/imbhargav5/rooks/blob/main/src/hooks/useSessionstorageState.ts

Solution 3

Parse the whole session storage on application start and put it as a state into a context. After that, on each "set" update both the context state and the sessionStorage. This solution has a lot of disadvantages like error proneness due to manual state to session storage synchronization, excessive re-rendering of the whole component tree under session storage provider on each storage value update. So I will not even add code examples here.

Related