Software Engineer
Software Engineer

Reputation: 16100

React Context: component not rendering

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.

Upvotes: 0

Views: 1917

Answers (3)

glinda93
glinda93

Reputation: 8459

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.

Upvotes: 1

maltoze
maltoze

Reputation: 737

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.

Upvotes: 1

ale917k
ale917k

Reputation: 1768

"...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.

Upvotes: 1

Related Questions