reactjsreact-state

Need to store JSX in React state


I want to store React component in React state but I think it is technically wrong. What you think about that?

Example

const LayoutContext = createContext(null as unknown as {
  setSidebarContent: React.Dispatch<React.SetStateAction<null | React.ReactNode>>;
})

const Layout = () => {
  const [sidebarContent, setSidebarContent] = useState<null | React.ReactNode>(
    null,
  );

  return (
    <LayoutContext.Provider value={{
      setSidebarContent
    }}>

  <div>
    <Sidebar>
      {sidebarContent}
    </Sidebar>
   <div className='page-container'>
      <Suspense fallback={<div>Loading...</div>}>
          <Outlet
      </Suspense>
    </div>
     
  </div>
  </LayoutContext.Provider>)

};

In this example with LayoutContext I provide setter for sidebar content, and I want to know if it will cause some problem with that approach, and if there are any other approaches to set content from child components?


Solution

  • @Iorweth333 is technically correct, but there are serious pitfalls to watch out for -

    function App() {
      const [state, setState] = React.useState(<Counter />)
      
      return <div>
        {state} click the counter ✅
        <hr />
        <button onClick={() => setState(<Counter />)} children="A" />
        <button onClick={() => setState(<Counter init={10} />)} children="B" />
        <button onClick={() => setState(<Counter init={100} />)} children="C" />
        change the counter ❌
      </div>
    }
    
    function Counter(props) {
      const [state, setState] = React.useState(props.init || 0)
      return <button onClick={() => setState(_ => _ + 1)} children={state} />
    }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(<App />)
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    The program runs, but changing from one component state to another does not work as we might expect. React detects that state in App changed, but when {state} is rendered, React doesn't know that is a new Counter component, as distinct from any other. So the same counter renders and the state stays the same.

    If you re-key the element containing the component stored in state, you can prompt a fresh Counter to be initialized. This is obviously a hack and should be avoided. This demo is only here to give better insight on how React "sees" things -

    function App() {
      const [state, setState] = React.useState(<Counter />)
      
      return <div key={Math.random() /* hack ❌ */ }>
        {state} click the counter ✅
        <hr />
        <button onClick={() => setState(<Counter />)} children="A" />
        <button onClick={() => setState(<Counter init={10} />)} children="B" />
        <button onClick={() => setState(<Counter init={100} />)} children="C" />
        change the counter ✅
      </div>
    }
    
    function Counter(props) {
      const [state, setState] = React.useState(props.init || 0)
      return <button onClick={() => setState(_ => _ + 1)} children={state} />
    }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(<App />)
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    Now that we can see with React Vision, we can understand why adding a unique key to each Counter component would also "fix" the problem -

    function App() {
      const [state, setState] = React.useState(<Counter key={0} />)
      
      return <div>
        {state} click the counter ✅
        <hr />
        <button onClick={() => setState(<Counter key={0} />)} children="A" />
        <button onClick={() => setState(<Counter key={1} init={10} />)} children="B" />
        <button onClick={() => setState(<Counter key={2} init={100} />)} children="C" />
        change the counter ✅
      </div>
    }
    
    function Counter(props) {
      const [state, setState] = React.useState(props.init || 0)
      return <button onClick={() => setState(_ => _ + 1)} children={state} />
    }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(<App />)
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    While I haven't seen this explicitly called out as an anti-pattern in the React docs, as seen above, it has potential to hide some bad bugs in your program. For this reason, I think you should avoid storing components as state.


    You asked how to make dynamic content another way, we will see that here. In React, we think about state as a snapshot, and our render is made up of props and state, conditional or derived. In this next series of code, we expand our knowledge incrementally, one demo at a time -

    function App() {
      const [tab, setTab] = React.useState(0) // ✅ state
      return <div>
        <nav>
          <button
            children="About"
            disabled={tab == 0} // ✅ derived props
            onClick={() => setTab(0)}
          />
          <button
            children="Contact"
            disabled={tab == 1}
            onClick={() => setTab(1)}
          />
        </nav>
        { tab == 0 ? <About />
        : tab == 1 ? <Contact /> // ✅  conditional render
        : null
        }
      </div>
    }
    
    function About() {
      return <p>About us</p>
    }
    
    function Contact() {
      return <p>Contact us</p>
    }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(<App />)
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    Above we see how a single number stored in tab can dynamically render content. But what if you want to supply the tabs dynamically? Here we learn how to render lists -

    function App(props) {
      const [tab, setTab] = React.useState(0)
      const TabFn = props.tabs[tab] // ✅ get tab constructor
      console.assert(TabFn, "tab out of bounds") // ✅ invariant
      return <div>
        <nav>
          {props.tabs.map((TabFn, index) =>
            <button
              key={TabFn.name}
              children={TabFn.name}
              disabled={tab == index}
              onClick={() => setTab(index)}
            />
          )}
        </nav>
        <TabFn />
      </div>
    }
    
    function Home() { return <p>Home</p> }
    function About() { return <p>About us</p> }
    function Contact() { return <p>Contact us</p> }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(
      <App tabs={[Home, Contact, About]} /> // ✅ dynamic content
    )
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    You might be wondering how to get data to our dynamic components. You can pass data deeply with a context. Here we begin adding a "dark mode" to our application. To keep the demo concise, only the nav and Home elements are responding so far -

    const defaultContext = { theme: "light" } // ✅ some data
    
    const Context = React.createContext(defaultContext) // ✅ create context
    
    function App(props) {
      const [theme, setTheme] = React.useState(defaultContext.theme)
      const [tab, setTab] = React.useState(0)
      const TabFn = props.tabs[tab]
      console.assert(TabFn, "tab out of bounds")
      return <Context.Provider value={{theme}}>
        <nav className={theme}>
          {props.tabs.map((TabFn, index) =>
            <button key={TabFn.name} children={TabFn.name} disabled={tab == index} onClick={() => setTab(index)} />
          )}
          <button
            onClick={() => setTheme(t => t == "light" ? "dark" : "light")}
            children={`theme: ${theme}`}
          />          
        </nav>
        <TabFn />
      </Context.Provider>
    }
    
    function Home() {
      const { theme } = React.useContext(Context) // ✅ access data
      return <p className={theme}>Home</p>
    }
    
    function About() { return <p>About us</p> }
    function Contact() { return <p>Contact us</p> }
    
    ReactDOM.createRoot(document.querySelector("#app")).render(
      <App tabs={[Home, Contact, About]} />
    )
    .dark {
      background-color: mediumblue;
      color: khaki;
    }
    <script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
    <div id="app"></div>

    Related: passing JSX as children to a component is normal. Note JSX can be passed in any prop, not limited to children -

    <UserProfile
      avatar={<Avatar src={user.avatar} />}
      name={user.name}
      email={user.email}
    />