Matthew Miller
Matthew Miller

Reputation: 605

Is there a way to treat React components as strings?

I'm working on a React with TypeScript application. I'm using a necessary library that has a component that takes a text property and renders it as an SVG. We'll call this library component <LibraryRenderer />.

I've wrapped this component in another component, <Renderer />, that passes the children (as text) into the library component like so:

function Renderer(props: { children: string }) {
  return <LibraryRenderer text={props.children} />;
}

Now, what I'd like is to create a component that transforms the text inside of it into another string and use in the <Renderer /> component. For instance, this new component <Wrap /> could surround existing text with "BEGIN" and "END" respectively. Here's something like what I'd want the code to look like:

function Wrap(props: { children: string }) {
  return `BEGIN ${props.children} END`; 
}

Then, you'd be able to use these components in a hierarchy like this:

<Renderer>
  <Wrap>
    <Wrap>
      Some Inner Text
    </Wrap>
  </Wrap>
</Renderer>

And I would want this to reduce to:

<LibraryRenderer text={"BEGIN BEGIN Some Inner Text END END"} />

I could write the text-transformation components as regular ol' functions but it seems like that would remove some of the nice flexibility of JSX syntax. Also, if I ever had props or state inside any of these text-transformation components, I wouldn't be able to use React to manage them.

function Wrap(value: string) {
  return `BEGIN ${props.children} END`;
}

<Renderer>
  {Wrap(
    Wrap(
      "Some Inner Text"
    )
  )}
</Renderer>

I feel like this might not exactly be the correct usage of React but I think that depends on if its possible and whether the code is "hacky" or not. Any help for how I should move forward would be greatly appreciated!

Upvotes: 1

Views: 1987

Answers (2)

AWolf
AWolf

Reputation: 8990

It's possible to generate the text with a Wrap component.

The markup in your app render method will be like following:

return (
    <Renderer>
        <Wrap>
          <Wrap>Hello world</Wrap>
        </Wrap>
    </Renderer>
);

This will generate the text BEGIN BEGIN Hello world END END inside of the Renderer component.

For the Wrap component we're using a span-tag to wrap our text with the following code:

const Wrap: FunctionComponent = ({ children }) => {
  return <span style={{ border: "1px solid red" }}>BEGIN {children} END</span>;
};

The Renderer component is getting the text with components as children with the following code (LibrarayRenderer will be explained later):

const Renderer: FunctionComponent = ({ children }) => {
  const [finalText, setText] = useState("");
  return (
    <div>
      <LibraryRenderer text={finalText} input={children} setText={setText} />
      <p>Generated text: {finalText}</p>
    </div>
  );
};

As you can see, we're creating a state variable finalText for our text that will be generated by our LibraryRenderer component. The input prop are the children with the wrapped text and setText is passed so we can update the finalText inside of the renderer. The finalText is also passed to the LibraryRender as this is needed there later (Note: It will be available on the second render because at the first render it's not ready.)

How to create the text with JSX markup?

Now, we're ready to generate the LibraryRenderer that will create the text for us.

type LibraryRendererProps = {
  text: string;
  input: ReactNode;
  setText: Dispatch<SetStateAction<string>>;
};

const LibraryRenderer: FunctionComponent<LibraryRendererProps> = ({
  text,
  input,
  setText
}) => {
  const ref = useRef<HTMLHeadingElement>(null);
  useEffect(() => {
    setText(ref?.current?.innerText || "");
  }, [ref, setText]);

  console.log("text", text); // needed for renderer as string
  // hiding the h1 with css because we're just using it to generate the text
  return (
    <h1 ref={ref} style={{ display: "none" }}>
      {input}
    </h1>
  );
};

We're returning a heading (any other inline element will work) with display: "none" style and a reference. The CSS is just used so it won't be displayed in the DOM. The reference is needed to get the text later. The {input} will render our children with the wrapped text.

Now, we have to do a useEffect with the ref as array parameter - so this useEffect will run if the reference changes. Inside of it, we're setting the finalText to the innerText of our rendered HTML by using our setText method. The HTML in DOM looks like:

<h1 style="display: none;">
  <span style="border: 1px solid red;">BEGIN <span style="border: 1px solid red;">BEGIN Hello world END</span> END</span>
</h1>

With innerText we're getting every text inside the h1-tag. The border style on the span is just for debugging (if display: none is removed). So we're now having the text BEGIN BEGIN Hello world END END.

Typing

I've used FunctionComponent for the type of our components, so children prop is defined there. You may have seen SFC type in other examples but this is deprecated because React function components are no longer considered stateless - use FunctionComponent instead.

If you'd like to add more types you can add them to the FunctionComponent as a generic type like in the above snippet with FunctionComponent<LibraryRendererProps>

The LibraryRendererProps type:

  • input: ReactNode; same typing as children types
  • text: string; our generated text as string
  • setText: Dispatch<SetStateAction<string>>; uses React.Dispatch and SetStateAction type with setState type as string

Demo

You can find the complete code below (with-out typing) or in the following Codesandbox with Typescript.

const { useState, useRef, useEffect } = React;

const LibraryRenderer = ({ text, input, setText }) => {
  const ref = useRef(null);
  useEffect(() => {
    setText(ref.current.innerText || "");
  }, [ref, setText]);

  console.log("text", text); // needed for renderer as string
  // hiding the h1 with css because we're just using it to generate the text
  return (
    <h1
      ref={ref}
      style={{
        display: "none",
      }}
    >{input}
    </h1>
  );
};

const Renderer = ({ children }) => {
  const [finalText, setText] = useState("");
  return (
    <div>
      <LibraryRenderer text={finalText} input={children} setText={setText} />
      <p>Generated text: {finalText}</p>
    </div>
  );
};

const Wrap = ({ children }) => {
  return (
    <span
      style={{
        border: "1px solid red",
      }}
    > BEGIN {children} END 
    </span>
  );
};

function App() {
  return (
    <div className="App">
      <Renderer>
        <Wrap>
          <Wrap>Hello world</Wrap>
        </Wrap>
      </Renderer>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);
body {
  font-family: Arial, sans-serif;
}
<script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<div id="root"></div>

Upvotes: 1

user9760669
user9760669

Reputation:

React is used to build the UI, but in your case you are operating on string, so using pure functions totally make sense.

In the last example, I will do something like

const generatedText = wrap(wrap("Some Inner Text"))
return <Renderer>{generatedText}</Renderer>

Upvotes: 0

Related Questions