Is it a misuse of context to use it to store JSX components for display elsewhere?
Asked Answered
A

4

7

I have an application where users will click various parts of the application and this will display some kind of configuration options in a drawer to the right.

The solution I've got for this is to have whatever content that is to displayed, to be stored in context. That way the drawer just needs to retrieve its content from context, and whatever parts of that need to set the content, can set it directly via context.

Here's a CodeSandbox demonstrating this.

Key code snippets:

const MainContent = () => {
  const items = ["foo", "bar", "biz"];

  const { setContent } = useContext(DrawerContentContext);
  /**
   * Note that in the real world, these components could exist at any level of nesting 
   */
  return (
    <Fragment>
      {items.map((v, i) => (
        <Button
          key={i}
          onClick={() => {
            setContent(<div>{v}</div>);
          }}
        >
          {v}
        </Button>
      ))}
    </Fragment>
  );
};

const MyDrawer = () => {
  const classes = useStyles();

  const { content } = useContext(DrawerContentContext);

  return (
    <Drawer
      anchor="right"
      open={true}
      variant="persistent"
      classes={{ paper: classes.drawer }}
    >
      draw content
      <hr />
      {content ? content : "empty"}
    </Drawer>
  );
};

export default function SimplePopover() {
  const [drawContent, setDrawerContent] = React.useState(null);
  return (
    <div>
      <DrawerContentContext.Provider
        value={{
          content: drawContent,
          setContent: setDrawerContent
        }}
      >
        <MainContent />
        <MyDrawer />
      </DrawerContentContext.Provider>
    </div>
  );
}

My question is - is this an appropriate use of context, or is this kind of solution likely to encounter issues around rendering/virtual dom etc?

Is there a tidier way to do this? (ie. custom hooks? though - remember that some of the components wanting to do the setttings may not be functional components).

Anaximenes answered 9/4, 2020 at 3:7 Comment(2)
Why can't you store different values as context data and render UI conditionally in the Drawer component based on those values?Bonneau
@Bonneau - Because actually the stuff I want to put in my drawer isn't 'just data'. They are likely to be different forms with custom interactions etc.Anaximenes
C
7

Note that it is fine to store components in Context purely on the technical point of it since JSX structures are nothing but Objects finally compiled using React.createElement.

However what you are trying to achieve can easily be done through portal and will give you more control on the components being rendered elsewhere post you render it through context as you can control the state and handlers for it better if you directly render them instead of store them as values in context

One more drawback of having components stored in context and rendering them is that it makes debugging of components very difficult. More often than not you will find it difficult to spot who the supplier of props is if the component gets complicated

import React, {
  Fragment,
  useContext,
  useState,
  useRef,
  useEffect
} from "react";
import { makeStyles } from "@material-ui/core/styles";
import Button from "@material-ui/core/Button";
import { Drawer } from "@material-ui/core";
import ReactDOM from "react-dom";

const useStyles = makeStyles(theme => ({
  typography: {
    padding: theme.spacing(2)
  },
  drawer: {
    width: 200
  }
}));

const DrawerContentContext = React.createContext({
  ref: null,
  setRef: () => {}
});

const MainContent = () => {
  const items = ["foo", "bar", "biz"];
  const [renderValue, setRenderValue] = useState("");
  const { ref } = useContext(DrawerContentContext);

  return (
    <Fragment>
      {items.map((v, i) => (
        <Button
          key={i}
          onClick={() => {
            setRenderValue(v);
          }}
        >
          {v}
        </Button>
      ))}
      {renderValue
        ? ReactDOM.createPortal(<div>{renderValue}</div>, ref.current)
        : null}
    </Fragment>
  );
};

const MyDrawer = () => {
  const classes = useStyles();
  const contentRef = useRef(null);
  const { setRef } = useContext(DrawerContentContext);
  useEffect(() => {
    setRef(contentRef);
  }, []);
  return (
    <Drawer
      anchor="right"
      open={true}
      variant="persistent"
      classes={{ paper: classes.drawer }}
    >
      draw content
      <hr />
      <div ref={contentRef} />
    </Drawer>
  );
};

export default function SimplePopover() {
  const [ref, setRef] = React.useState(null);
  return (
    <div>
      <DrawerContentContext.Provider
        value={{
          ref,
          setRef
        }}
      >
        <MainContent />
        <MyDrawer />
      </DrawerContentContext.Provider>
    </div>
  );
}

CodeSandbox demo

Containment answered 12/4, 2020 at 16:17 Comment(3)
You can make the above code better by restructuring your code so that you don't even need to use context to store ref and can just pass it from the parent. Also you can generalise the part where you use ReactDOM.createPortalContainment
The problem with this example is that multiple things use the ref to put contentn in there. (in this case that doesn't happen because the use of the ref only happens in one component) That might be OK in some cases, but not in many. See this example for what I mean: codesandbox.io/s/material-demo-mrmw8?file=/demo.js:2047-2053Anaximenes
Better example here: codesandbox.io/s/material-demo-w3lc0?file=/demo.js The way I've done this I can force there to be only 'TheContent' compeonnt at a time, and use that to control active state. I'm curious how you wlould do this.Anaximenes
K
3

Regarding performance, components subscribed to a context will rerender if the context changes, regardless of whether they store arbitrary data, jsx elements, or React components. There is an open RFC for context selectors that solves this problem, but in the meantime, some workarounds are useContextSelector and Redux.

Aside from performance, whether it is a misuse depends on whether it makes the code easier to work with. Just remember that React elements are just objects. The docs say:

React elements are plain objects and are cheap to create

And jsx is just syntax. The docs:

Each JSX element is just syntactic sugar for calling React.createElement(component, props, ...children).

So, if storing { children: 'foo', element: 'div' } is fine, then in many cases so is <div>foo</div>.

Keeleykeelhaul answered 12/4, 2020 at 1:41 Comment(0)
B
1

Yes you can do that since React components are just objects and you can store objects as context values. But there are few problems in doing so, now you won't be able to use stop unnecessary rerenders because the objects by React will always have a different reference so your child components will rerender every time.

What I will suggest is to store simple values in your context and render the UI conditionally wherever you want to.

Bonneau answered 12/4, 2020 at 0:53 Comment(0)
D
0

I use the following logic in my projects for better performance, and more flexibility, which you can use in designing your page layouts, And you can also develop it your own way. In this method, the context is not used, and instead the ref and useImperativeHandle are used.

useImperativeHandle is very similar, but it lets you do two things:

  1. It gives you control over the value that is returned. Instead of returning the instance element, you explicitly state what the return value will be (see snippet below).
  2. It allows you to replace native functions (such as blur, focus, etc) with functions of your own, thus allowing side-effects to the normal behavior, or a different behavior altogether. Though, you can call the function whatever you like.

useImperativeHandle customizes the instance value that is exposed to parent components when using ref

For more information:

React docs: useImperativeHandle

Stackoverflow question: when to use useImperativeHandle ...


Demo of my example: codesandbox

Structure of my example:

src
 |---pages
 |     |---About.js
 |     |---Home.js
 |
 |---App.js
 |---CustomPage.js
 |---DrawerSidebar.js
 |---index.js

index.js file:

import React from "react";
import ReactDOM from "react-dom";

import App from "./App";

import { BrowserRouter as Router } from "react-router-dom";

const rootElement = document.getElementById("root");
ReactDOM.render(
  <Router>
    <App />
  </Router>,
  rootElement
);

App.js file:

import React from "react";
import "./styles.css";
import { Switch, Route, Link } from "react-router-dom";
import Home from "./pages/Home";
import About from "./pages/About";

export default function App() {
  return (
    <div className="App">
      <ul>
        <li>
          <Link to="/">Home</Link>
        </li>
        <li>
          <Link to="/about">About</Link>
        </li>
      </ul>
      <Switch>
        <Route path="/" exact component={Home} />
        <Route path="/about" exact component={About} />
      </Switch>
    </div>
  );
}

CustomPage.js file:

import React, { useRef, useImperativeHandle } from "react";

import DrawerSidebar from "./DrawerSidebar";

const CustomPage = React.forwardRef((props, ref) => {
  const leftSidebarRef = useRef(null);
  const rootRef = useRef(null);

  useImperativeHandle(ref, () => {
    return {
      rootRef: rootRef,
      toggleLeftSidebar: () => {
        leftSidebarRef.current.toggleSidebar();
      }
    };
  });

  return (
    <div className="page">
      <h1>custom page with drawer</h1>
      <DrawerSidebar position="left" ref={leftSidebarRef} rootRef={rootRef} />

      <div className="content">{props.children}</div>
    </div>
  );
});

export default React.memo(CustomPage);

DrawerSidebar.js file:

import React, { useState, useImperativeHandle } from "react";
import { Drawer } from "@material-ui/core";

const DrawerSidebar = (props, ref) => {
  const [isOpen, setIsOpen] = useState(false);

  useImperativeHandle(ref, () => ({
    toggleSidebar: handleToggleDrawer
  }));

  const handleToggleDrawer = () => {
    setIsOpen(!isOpen);
  };

  return (
    <React.Fragment>
      <Drawer
        variant="temporary"
        anchor={props.position}
        open={isOpen}
        onClose={handleToggleDrawer}
        onClick={handleToggleDrawer}
      >
        <ul>
          <li>home</li>
          <li>about</li>
          <li>contact</li>
        </ul>
      </Drawer>
    </React.Fragment>
  );
};

export default React.forwardRef(DrawerSidebar);

About.js file:

import React, { useRef } from "react";
import CustomPage from "../CustomPage";

const About = () => {
  const pageRef = useRef(null);

  return (
    <div>
      <CustomPage ref={pageRef}>
        <h1>About page</h1>
        <button onClick={() => pageRef.current.toggleLeftSidebar()}>
          open drawer in About page
        </button>
      </CustomPage>
    </div>
  );
};

export default About;

Home.js file:

import React, { useRef } from "react";
import CustomPage from "../CustomPage";

const Home = () => {
  const pageRef = useRef(null);

  return (
    <div>
      <CustomPage ref={pageRef}>
        <h1>Home page</h1>
        <button onClick={() => pageRef.current.toggleLeftSidebar()}>
          open drawer in Home page
        </button>
      </CustomPage>
    </div>
  );
};

export default Home;
Dissidence answered 16/4, 2020 at 21:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.