1

I'm using react-localization for i18n that uses an object with a setLanguage method. I'm trying to build a language switcher for it, but when I change the language, the component doesn't re-render with the updated object.

I've tried with useEffect, useCallback, useRef. The only thing that works is useCallback, and exporting the callback from my custom hook, then calling it when the component renders, which I think is very ugly and incorrect.

What's the proper way to mutate an object from a hook and have the component that uses it be re-rendered?

Here's the CodeSandbox I created to test this: https://codesandbox.io/s/react-localization-context-wfo2p2

translation.js

import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState
} from "react";

const TranslationContext = createContext({
  language: "fr",
  setLanguage: () => null
});

const TranslationProvider = ({ children }) => {
  const [language, setLanguage] = useState("fr");

  return (
    <TranslationContext.Provider value={{ language, setLanguage }}>
      {children}
    </TranslationContext.Provider>
  );
};

const useTranslation = (_strings, key) => {
  const { language, setLanguage } = useContext(TranslationContext);

  const ref = useRef(_strings);
  const cb = useCallback(() => _strings.setLanguage(language), [
    language,
    _strings
  ]);

  useEffect(() => {
    ref?.current?.setLanguage(language);
  }, [language]);

  return { language, cb, setLanguage };
};

export { useTranslation, TranslationProvider, TranslationContext };
Drew Reese
  • 165,259
  • 14
  • 153
  • 181
buzzb0x
  • 629
  • 1
  • 7
  • 10

2 Answers2

1

The language state is in the parent context provider component, so it'll re-render affected child components, so you don't need extra logic like useEffect.

I'll be honest, I tried to call the method directly in the custom hook and it worked ;P.

// translation.js
const useTranslation = (_strings, key) => {
  const { language, setLanguage } = useContext(TranslationContext);
  _strings?.setLanguage(language);
  return { language, setLanguage };
};
// Test.js
const Test = () => {
  useTranslation(strings, "Test");
  return <div>{strings.message}</div>;
};

Edit react-localization context (forked)

If you want to run the localization only on language state updates, we'll need useEffect, and to make sure the components update after useEffect, which runs after the component's rendered, runs, so we create a state that we update in the component/custom hook.

You can use a boolean state that you flip each update, but you can also save the properties of the localized object to the state by destructuring it. Then use that state for the rendering.

const useTranslation = (_strings, key) => {
  const { language } = useContext(TranslationContext);
  const [fields, setFields] = useState({..._strings});
  
  useEffect(() => {
    _strings.setLanguage(language)
    setFields({..._strings});
  }, [language]);

  return fields;
};
const Test = () => {
  const fields = useTranslation(_strings, "Test");
  return <div>{fields.message}</div>;
};

Edit react-localization context (forked)

Brother58697
  • 2,290
  • 2
  • 4
  • 12
  • Well, I'll admit this works! Thanks for your suggestion. However, I can't believe this would be considered a very React way to do it. What do you think? – buzzb0x Oct 03 '22 at 19:29
  • Look at my edit about the language state. You're using context and state properly, so it's fine to do it this way imo. If you want to make it a bit cleaner, you can separate the state from the locale updater. It's gonna be a bit redundant, but also clearer. Also custom hooks are essentially like copying the code block into the component, so I think it's fine to abstract away the context getting and local update into the custom hook. Why do you think it's not very React? – Brother58697 Oct 03 '22 at 19:33
  • I think it's not very React because the hook is used to mutate the object each time it is rendered as opposed to only when the language is being updated. it just feels like it should be part of a `useEffect` and use a `ref` of some sort. What do you think? What happens in terms of performance if I have lots of components using the same mechanism? – buzzb0x Oct 03 '22 at 20:07
  • 1
    I updated my answer to account for this. I don't see a need to use a `ref` since we're not using callbacks that are initialized with old states, instead the new state's being set from the localization object which in itself is outside the component. – Brother58697 Oct 04 '22 at 14:04
1

You are actually closer than you realize. Instead of returning a callback cb to the UI to call as an unintentional side-effect, create/move some state into the hook.

The TranslationProvider stores the current global language state, but the useTranslation hook should/could store it's own reference to a LocalizedStrings object and store the translated messages in a local state that is updated via a useEffect hook.

Example:

const useTranslation = (_strings, key) => {
  // Access global language state and setter
  const { language, setLanguage } = useContext(TranslationContext);

  // Local message state for translations
  const [message, setMessage] = useState();

  // LocalizedStrings instance ref
  const stringsRef = useRef(_strings && new LocalizedStrings(_strings));

  // Side-effect to translate to current language
  // Update message state to trigger rerender
  useEffect(() => {
    stringsRef.current?.setLanguage(language);
    setMessage(stringsRef.current?.message);
  }, [language]);

  // Expose context value and current translated message
  return { language, message, setLanguage };
};

Usage:

const strings = {
  en: { message: "Test message in English" },
  fr: { message: "Message de test en Français" }
};

const Test = () => {
  const { message } = useTranslation(strings, "Test");

  useEffect(() => {
    console.log("[RENDER <Test/>", strings);
  });

  return <div>{message}</div>;
};

...

const _strings = {
  en: { message: "Test message #2 in English" },
  fr: { message: "Message de test #2 en Français" }
};

const Test2 = () => {
  const { language, message } = useTranslation(_strings, "Test2");

  useEffect(() => {
    console.log("[RENDER] <Test2/>", _strings);
  })

  return (
    <div>
      {message} {language}
    </div>
  );
};

Edit how-to-update-a-react-component-when-mutating-an-object

Drew Reese
  • 165,259
  • 14
  • 153
  • 181
  • Thanks for the suggestion! This way it avoids having to import the localization package everywhere actually, and you can just return the current object reference from the hook, and as long as you call a `setState` in the `useEffect`, the component will rerender. – buzzb0x Oct 04 '22 at 06:07
  • 1
    @EthanO. The "state" you enqueue to trigger a rerender needs to be dynamic so React doesn't [bail out of the state update](https://reactjs.org/docs/hooks-reference.html#bailing-out-of-a-state-update)... using the new translated message just makes sense here, but flipping a boolean or incrementing a count would accomplish the same thing. – Drew Reese Oct 04 '22 at 06:13