0

In my React 17.0.1 SPA translation app I have a page with 2 sections. The top is for input and the bottom is for translations of that input. I have had problems using a single context/state for this page because the translation is performed by a remote API, and when it returns I can't update the UI without updating the input part and resetting it to whatever value it held at the time the translation API was called.

So, I have split the page into two contexts, one for input and one for translations. So far so good. The rendering for the translations works the first time I do it. But, despite the state in the translation-context changing when needed via its reducer (I have logging that proves that) the appropriate part of the page doesn't re-render in step with those changes. As I said, this is odd because the first time I change the state this way it does actually rerender.

This sketch shows the basic structure in more detail, but I have left a lot out:

App.js

import { React, useRef, createContext, useReducer } from "react";
import ReactDOM from 'react-dom';
import { Form } from "react-bootstrap";
import { Translation } from "./Translation";

const NativeContext = createContext();
const TranslationsContext = createContext();

const nativeReducer = (state, action) {...}
const translationsReducer = (state, action) {...}

const App = () {
  const inputControl = useRef(null);
  const [nativeState, nativeDispatch] = useReducer(nativeReducer, {});
  const [translationsState, translationsDispatch] = useReducer(translationsReducer, {});
  const handleChange = async () => {
    // fetch from external API
    translationsDispatch({ type: "TX", payload: { text: text, translations: data.translations } });
  }

  return (
    <NativeContext.Provider value={{ nativeState, nativeDispatch }}>
      <Form.Control key="fc-in" autoFocus ref={inputControl} as="textarea" id="inputControl" defaultValue={nativeState.text} onChange={textChanged} />
    </NativeContext.Provider>
    <TranslationsContext.Provider value={{ translationsState, translationsDispatch }}>
      {translationsState.translations.map((item, index) => {
        return <Translation index={index} item={item} to={item.language} key={`tx-${item.language}`} />;
      })}
    </TranslationsContext.Provider>
  );
}

I have the usual index.js kicking it all off:

index.js

  ReactDOM.render(<React.StrictMode><App /></React.StrictMode>,document.getElementById('root'));

The input control is so that I can get the current value from the input to translate. I have a 500ms timer-based denounce in there that makes using the updated react state way too hard for that purpose. It isn't relevant to this problem though.

The reducers, not shown are both of the form:

const nativeReducer = (state, action) => {
  switch (action.type) {
    case "TEXT":
      return {
        ...state,
        text: action.payload.text
      };
  }
};

The translations reducer is too big to show here, as it deals with maintaining a cache and other information regarding other UI components not shown here. I also haven't shown the fetch for the API, but I'm sure your imagination can fill that in.

The translation state actually looks like this:

const initialTranslationsState = {
  to: ["de"],
  translations: [
    {
      language: "de",
      text: "Ich hab keine lust Deutsche zu sprechen"
    }
  ]
};

The Translation component is rather simple:

Translation.js

import { React, useContext } from "react";

export const Translation = props => {
  const { translationsState, translationsDispatch } = useContext(TranslationsContext);
  
  return (
    <Form.Control as="textarea" defaultValue={translationsState.translations[props.index].text} readOnly />
  );
}

The props that are passed in contain data pertaining to which of the translations this particular instance of the component is supposed to reference. I didn't want to pass the translation itself in the props, so only the index is passed in the props. The translation itself is passed in the context.

When I enter the page there is already some text in the NativeContext (that refers to native language btw), and I have a useEffect clause that calls translate to kick the whole thing off. That causes the translations to be updated and after a second or two, I see the screen components rendering the results without updating the input text (which it turns out is very important).

But, when I then go and edit the input text, the translation results aren't rendered. As I mentioned I have logging to check the state, and it has changed appropriately, but the subcomponent that renders them on the page isn't being rendered so I only see the output in the console. This happens no matter how often I update the input text.

I am a bit stuck with this. I have reviewed similar questions on stackoverflow, but they mainly relate to people trying to mutate state, which I am not doing, or are related to the older class-based approach, which was flawed by design anyway. Let me know if you need more info to help work this out.

1
  • Have you tried using value instead of defaultValue on Form.Control? Commented Jan 18, 2021 at 8:17

3 Answers 3

1
+100

Changing the value of defaultValue attribute after a component has mounted will not cause any update of the value in the DOM.

https://reactjs.org/docs/uncontrolled-components.html#default-values

Try to use value instead of defaultValue on Form.Control.

Sign up to request clarification or add additional context in comments.

1 Comment

OMG -- and I wasted a hundred bounty points and a day on that!!! Sometimes working alone is the worst thing ever. Thanks so much for the code review. SO won't allow me to award the bounty for another 22 hours, but I'll send it asap.
1

"...That causes the translations to be updated and after a second or two, I see the screen components rendering the results without updating the input text"

Once you fetch the translations, you're dispatching that value to the translationContext alone. What that implies, is all of the components consuming it will re-render.

Your input component, which assume you refer to the one wrapped inside the NativeProvider, does not consume the translationProvider and as such, will not listen to any related change.

In order to fix this, you may need to call another dispatch as nativeDispatch on your handleChange post fetching, or use a single context and make both input and translations components listen to it.

Note on a side, you should use the property defaultValue on uncontrolled inputs only, so you should prefer to use value instead if you use controlled inputs, which are inputs listening to state changes.

To read more about controlled inputs, have a look here: https://www.xspdf.com/resolution/50491100.html#:~:text=In%20React%2C%20defaultValue%20is%20used,together%20in%20a%20form%20element.

1 Comment

Actually, the intent is to explicitly not render the input component in that way. I had the problem of selections and input position being lost whenever it rendered, which is why I split it up this way in the first place. The term Native refers to the native language of the user. They should be able to type continuously while the translations update in (almost) real-time as they work (I actually have a short denounce in there so that it only updates when they micro-pause, rather than on each keypress).
1

TL,DR: Working Example

I'm not sure what you were trying to do with useRef and textChanged because the original source code has been omitted.

You may wanted to call API when text input value has been changed. Do not resort to useRef. You can use useEffect to observe the status of nativeState and call async API and dispatch translation TX action.

I prepared a working example for you:

App.js

import React, { useReducer, useEffect, useCallback } from "react";
import Translation from "./components/Translation";
import Input from "./components/Input";
import NativeContext from "./contexts/native";
import TranslationsContext from "./contexts/translation";
import nativeReducer from "./reducers/native";
import translationsReducer from "./reducers/translation";
import { delay } from "./utils/delay";

const App = () => {
  const [nativeState, nativeDispatch] = useReducer(nativeReducer, {
    text: ""
  });
  const [translationsState, translationsDispatch] = useReducer(
    translationsReducer,
    {
      to: "de",
      translations: []
    }
  );

  const fetchTranslations = useCallback(
    async (text) => {
      if (!text) {
        return;
      }
      await delay(500);
      translationsDispatch({
        type: "TX",
        payload: {
          to: "de",
          translations: [
            {
              text: `de_${text}_1`
            },
            {
              text: `de_${text}_2`
            },
            {
              text: `de_${text}_3`
            }
          ]
        }
      });
    },
    [translationsDispatch]
  );

  useEffect(() => {
    fetchTranslations(nativeState.text);
  }, [nativeState, fetchTranslations]);

  return (
    <div>
      <NativeContext.Provider value={{ nativeState, nativeDispatch }}>
        <Input />
      </NativeContext.Provider>
      <TranslationsContext.Provider
        value={{ translationsState, translationsDispatch }}
      >
        <ul>
          {translationsState.translations.map((item, index) => {
            return <Translation index={index} key={`tx-index-${item.text}`} />;
          })}
        </ul>
      </TranslationsContext.Provider>
    </div>
  );
};

export default App;

components/Input.js

import React, { useCallback, useContext } from "react";
import NativeContext from "../contexts/native";
const Input = () => {
  const { nativeState, nativeDispatch } = useContext(NativeContext);

  const handleChange = useCallback(
(e) => {
  const { value } = e.target;
  nativeDispatch({ type: "TEXT", payload: { text: value } });
},
[nativeDispatch]
  );

  return <input type="text" value={nativeState.text} onChange={handleChange} />;
};

export default Input;

components/Translation.js

import React, { useContext } from "react";
import TranslationsContext from "../contexts/translation";

const Translation = ({ index }) => {
  const { translationsState, ...rest } = useContext(TranslationsContext);
  return <li>{translationsState?.translations[index].text}</li>;
};

export default Translation;

contexts

import { createContext } from "react";

// NativeContext.js
export default createContext({
  nativeState: { text: "" },
  nativeDispatch: () => {}
});

// TranslationsContext.js

export default createContext({
  translationsState: { to: "de", translations: [] },
  translationsDispatch: () => {}
});

reducers

// nativeReducer

const initialNativeState = {
  text: ""
};

export default function (state = initialNativeState, action) {
  switch (action.type) {
case "TEXT":
  return {
    ...state,
    text: action.payload.text
  };
default:
  return state;
  }
}

// translationsReducer

const initialTranslationsState = {
  to: "de",
  translations: []
};

export default function (state = initialTranslationsState, action) {
  switch (action.type) {
case "TX":
  const newState = {
    ...state,
    to: action.payload.to,
    translations: action.payload.translations
  };
  return newState;
default:
  return state;
  }
}

utils/delay.js

export const delay = (timeout = 1000) => {
  return new Promise((resolve) => {
setTimeout(() => {
  resolve();
}, timeout);
  });
};

Possible cause of error:

  • You may have overlooked some values in dependency arrays
  • States of values created by useRef are not tracked by React, thus they do not trigger re-render
  • Trying to sync nativeState and translationsState in a possibly ugly way. I'm not sure because I haven't seen the code.

6 Comments

Thanks for this. The useRef is because I have a timer process that is fired whenever the user hits a key that resets if the user hits another key within a short timespan (~250ms). If they don't hit another key then it invokes the translation and updates the outputs. The problem being -- getting the latest value of the input box at the time I translate, not the value that was in react's state at point at which the timer function was rendered. I know its hacky, but I can't see any other way of getting the debounce to work.
And, actually, it all works. I've awarded the right answer. In my complexity-blindness, I didn't notice that I was setting the defaultValue rather than the value on the translation (output) controls.
I don't think it's relevant. And, it wasn't :)
And, sorry for omitting the original source -- there's a lot more to it than the code I've put here and a lot of it is proprietary. This is supposed to be a sketch that shows the structure and core of the problem.
Thought it was a complex problem from a high rep user :P
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.