DEV Community

Harry
Harry

Posted on • Edited on

The unspoken issue with using documentToReactComponents with the Contentful Javascript client

TL;DR

The best way to render React components from Contentful data is to use documentToReactComponents in conjunction with the Contentful Javascript client, however you have to use it in a very specific way that is not obvious due to poor documentation. Solution at the end.


Imagine you have a Contentful entry. One of its fields is rich text. Somewhere amongst your rich text you have inline embedded entry.

Embedded entry link as seen in the Contentful rich text editor.

You simply want to render the rich text on a React-based Gatsby site. You can use the documentToReactComponents function from the @contentful/rich-text-react-renderer module to do it:

import { documentToReactComponents } from "@contentful/rich-text-react-renderer";

// Contentful client config
const contentful = require("contentful");
const client = contentful.createClient({
  space: "abcd12345efgh",
  accessToken: "_gdH6uD7325BnjfNSEG95HjQk75G5HnS344F90",
});

export default function CasePage({ data }) {
  return (
    <Container maxWidth="sm">
      <Typography variant="h4" display="inline">
        {data.contentfulCase.title}&nbsp;&nbsp;
      </Typography>
      <Typography variant="h4" display="inline" sx={{ fontWeight: 700 }}>
        {data.contentfulCase.yearOfOccurence}
      </Typography>
      {data.contentfulCase.body?.raw &&
        documentToReactComponents(JSON.parse(data.contentfulCase.body.raw))}
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

The frontend showing the default content that is rendered when documentToReactComponents finds an embedded entry link. It is a messy string looking like 'type: embedded-entry-inline id: 345hkj3bjbfkjehjkh34kjkg'.

That doesn't look right. You would expect it to atleast be a hyperlink. This is caused by the documentToReactComponents() default node renderer for inline embedded entries: https://github.com/contentful/rich-text/blob/cd42e95489da4fcb9221008c7d0cee815ecf9cc3/packages/rich-text-react-renderer/src/index.tsx#L33

You need to override this default using the options object, which you pass as the second paramater to documentToReactComponents(). Here's the code as above, but just more focused on the bits that have changed:

...
export default function CasePage({ data }) {

    const options = {
        renderNode: {
            [INLINES.EMBEDDED_ENTRY]: (node, children) => (
                <Typography component={Link} to="/cases">
                    the title of the embedded entry will go here
                </Typography>;
            )
        }
    }

    return (
        ...
        {data.contentfulCase.body?.raw &&
        documentToReactComponents(JSON.parse(data.contentfulCase.body.raw), options)}
        ...
    )
}
Enter fullscreen mode Exit fullscreen mode

Link rendered on the frontend containing dummy text

Great. A link is rendering. Now you just need the link to be the title of the embedded entry, instead of that placeholder text. For example, Washington DC.

Maybe the title is in the node or children variables? Use console.log() to check:

Browser console showing the contents of the variables node and children. node is populated but children is an empty array.

The title isn't there, but that's by design - Contentful responses keep the raw body structure separate from the assets and links etc embedded in the rich text. The raw structure simply references these things by their id.
To get the entry data, the easiest way it to give its id to the getEntry() method of the Contentful client. This does the 'id-to-entry' resolution for you:

...
export default function CasePage({ data }) {

    const options = {
        renderNode: {
            [INLINES.EMBEDDED_ENTRY]: (node, children) => (
                <Typography component={Link} to="/cases">
                    {client
                        .getEntry(node.data.target.sys.id)
                        .then((entry) => console.log(entry))}
                </Typography>
            )
        }
    }

    return (
        ...
Enter fullscreen mode Exit fullscreen mode

Gatsby error in browser stating objects are not valid as a React child. Object type is a Promise. The browser console is also visible and shows the an object containing the data we need, including the entry title.

That's bad. Even though the console.log() doesn't return anything, it seems React never looks further than getEntry() and freaks out that a Promise is coming, instead of JSX, a string, or other valid React stuff. The good news is that you can see getEntry() successfully resolved the entry and the title is there that you need for the link text.

Maybe the Promise issue can be solved by putting the client.getEntry() code in the function body, then only return JSX with your title injected:

...
export default function CasePage({ data }) {

    const options = {
        renderNode: {
            [INLINES.EMBEDDED_ENTRY]: (node, children) => {
                const myentry = client
                        .getEntry(node.data.target.sys.id)
                        .then((entry) => entry.fields.title)
                return (
                    <Typography component={Link} to="/cases">
                        {myentry}
                    </Typography>
                )
            }
        }
    }

    return (
        ...
Enter fullscreen mode Exit fullscreen mode

Same error:

Gatsby error in browser again stating objects are not valid as a React child. Object type is a Promise.

Maybe if you use React state, and call its set method in the callback you can get the entry object out. That way you can use the contents of the state in the JSX:

...
export default function CasePage({ data }) {

    const options = {
        renderNode: {
            [INLINES.EMBEDDED_ENTRY]: (node, children) => {
                // Create the state
                const [entry, setEntry] = React.useState({});
                // Get the entry content and give it straight to setEntry()
                client
                  .getEntry(node.data.target.sys.id)
                  .then((the_entry) => setEntry(the_entry));
                return (
                  <Typography component={Link} to="/cases">
                    {entry.fields.title}
                  </Typography>
                );
            },
        }
    }

    return (
        ...
Enter fullscreen mode Exit fullscreen mode

React error stating useState cannot be called inside a callback function

Another error. Even if there's a way to see setEntry() from inside the callback function, React will not let you call it.

Maybe if you move the state and client.getEntry() code completely outside the options object into its own function, you can call said function to get the title into your JSX:

...
export default function CasePage({ data }) {

    // Create the state
    const [entry, setEntry] = React.useState({ placeholder: "placeholder" });

    function fetchData(entryId) {
      // Get the entry content and give it straight to setEntry()
      client.getEntry(entryId).then((theentry) => {
        setEntry(theentry);
        console.log(`The type of theentry is ${typeof theentry}`);
        console.log(theentry);
        console.log(`The type of entry is ${typeof entry}`);
        console.log(entry);
      });
    }

    const options = {
        renderNode: {
            [INLINES.EMBEDDED_ENTRY]: (node, children) => {
                // fetchData sets the state to the result of client.getEntry()
                fetchData(node.data.target.sys.id);
                return (
                  <Typography component={Link} to="/cases">
                    {entry.fields.title}
                  </Typography>
                );
            },
        }
    }

    return (
        ...
Enter fullscreen mode Exit fullscreen mode

The setEntry() call in the new function did not work for a reason unknown to me, so entry is still what it was initialised with (as seen in the console):

React error in browser stating entry.fields is undefined

Maybe if we forget about React state and use the async and await keywords instead of .then().

...
export default function CasePage({ data }) {

    async function fetchData(entryId) {
        const entrydata = await client.getEntry(entryId);
        console.log(`The type of entrydata is ${typeof entrydata}`);
        console.log(entrydata);
        // Yep, the console shows that entrydata is the object we want. Let's return that.
        return entrydata;
    }

    const options = {
        renderNode: {
            [INLINES.EMBEDDED_ENTRY]: (node, children) => {
                const data = fetchData(node.data.target.sys.id);
                console.log(`The type of data is ${typeof data}`);
                console.log(data);
                return (
                    <Typography component={Link} to="/cases">
                        {data.fields.title}
                    </Typography>
                );
            },
        }
    }

    return (
        ...
Enter fullscreen mode Exit fullscreen mode

entry.fields is undefined because data is a Promise.
React error in browser stating entry.fields is undefined.


If you came here looking for a solution, you're in luck. After 11 days of talking to people in real life about the issue, digging hard into Google, and messaging people on Discord servers, I had an idea to move the options object outside the component, and then a conversation with someone on Discord led me to implement state inside the options object. Here's the full code:

...
// This object is outside the component as seen in the Gatsby examples. 
// See it being passed to the documentToReactComponents method in the 
// CasePage component below
const options = {
  renderNode: {
    [INLINES.EMBEDDED_ENTRY]: (node, children) => {
      // This block runs once per embedded inline entry found in the raw structure

      // Create the state
      const [entry, setEntry] = React.useState("");

      // Get the entry, then set entry state to be the value of the Promise
      // This will only run again if node.data.target.sys.id has a different value
      React.useEffect(() => {
        console.log("Fetching entry found embedded in the rich text");
        client
          .getEntry(node.data.target.sys.id)
          .then((value) => setEntry(value.fields));
      }, [node.data.target.sys.id]);

      return (
        // Use the entry state to interpolate the components
        <Typography component={Link} to={`/cases/${entry.slug}`}>
          {entry.title}
        </Typography>
      );
    },
  }
}

export default function CasePage({ data }) {

  return (
    <Container maxWidth="sm">
      {documentToReactComponents(
        JSON.parse(data.contentfulCase.body.raw),
        options
      )}
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

Link properly rendered on the page using data from Contentful

Top comments (0)