DEV Community

Cover image for Don't Put Your Data In React
Marais Rossouw
Marais Rossouw

Posted on

Don't Put Your Data In React

We've all got applications with views. You know, views, the macro components we use to compose together to build our pages. These pages then tie in with our routes to build our applications.

This post will walk you through creating something that resembles Relay EntryPoints, what that means for these views. And how it's not really a story about user interfaces, but a story about where your data lives.

Enter EntryPoint's

We have a layout of views, a collection we love to call pages. But let's think for a moment, what is that really?

In React we like to think we are composing components, you know, the thing you put inside off another thing1. So let's riff of that for a second.

our app

We can see we have something that looks like GitHub:

  • the global nav, persistent across multiple pages
  • the project header, persistent across multiple project tabs
  • the code tab, or the "main content"

All views or components that when composed together build up the repository page. Our page composed all those together into a single root node we're going to call the RepositoryView.

Before terminology gets out of hand, let's instead refer to it as "composing layouts of entry points".

So our entry point here would be the page itself, the RepositoryViewEntryPoint, a ProjectHeaderEntryPoint and of course the main content as well.

All these things are the building blocks of our application — an "entry point" into a piece of code (its view) and it's data.

Let's unpack what Entry Points are, and come full circle towards the end as we build GitHub.

EntryPoints are type-safe

Before we get into how Relay handles this, let's build our own! 😅

There is really 3 parts that go into making an entry point.

  1. the type definition of what an entry point even is
  2. a component
  3. and an entry point code to that component
// 1. the type definition
type EntryPoint<Props> = {
    component: ComponentType<Props>,
    props: Props
};

// 2. the component
type Props = { user: { name: string } };
const Howdy: SFC<Props> = ({ user }) => (
    <div>Hello {user.name}</div>
);

// 3. the entry point 
const HowdyEntryPoint: EntryPoint<Props> = {
    component: Howdy,
    props: { user: { name: 'Mona' } },
};
Enter fullscreen mode Exit fullscreen mode

... and now you think I've completely lost it! 😅 "You've just put the components props next to something that already defines it" ... but bare with me.

What we've done here is, we've established a container creating a type-safe dependency between the component and it's data.

Don't let me understate that, one of the powerful aspects of components especially with the help of Flow and TypeScript is the ability to define component prop types. So as we venture into this notion of "Don't Put Data in React", we need to retain this type-safe aspect.

If the component requires a new set of data requirements, since we've established this type-safe dependency between them, you won't forget to also give those new data to the component — your type-checker will have a whinge.

But how have we moved the data out of React? Really in the literal sense: <Howdy user={{ name: 'mona' }} />, has the { name: 'mona' } as data declare in-react. So we've moved the data alongside a reference to the component, in the form of a joining object, the EntryPoint.

Great! Let's render this to the screen, which would happens as you'd might expect:

const EntryPointContainer: SFC<{ entrypoint: EntryPoint<unknown> }> = ({
    entrypoint: {
        component: Component,
        props,
    },
}) => (
    <Component {...props} />
);

<EntryPointContainer entrypoint={HowdyEntryPoint} />
Enter fullscreen mode Exit fullscreen mode

The EntryPointContainer here takes a reference to an entry point, and wires ups the props and renders.

EntryPoints describe the data dependency

Now! Wouldn't the world be wonderful if all we ever had was static data. If we did, the post would stop here 😂! Instead we live in a world where our data lives in remote places, databases, apis, your great aunts show box.

So let's reframe our mindset a little, instead of asking "what data goes with this component" lets ask "what query do I run to get the data for this component".

An entry point describes the data dependency.

Well, what does it mean to describe?

to give a report of how something is doneCambridge

Notice how it's "how something is done", not "what the something is".

In terms of software, how do we describe how data is done, or fetched? Through a function perhaps? A function describes how data is resolved, not it's result.

Let's describe the data dependency and change our example to reflect this:

type EntryPoint<Variables, Props> = {
    component: ComponentType<Props>,
    fetch: (variables: Variables) => Promise<Props>,
    variables: Variables
};

const HowdyEntryPoint: EntryPoint<{ userId: string }, Props> = {
    component: Howdy,
    fetch(variables) {
        return fetchGraphql(graphql`query($id: ID) { user(id: $id) { name }}`);
    },
    variables: { userId: 2 },
};
Enter fullscreen mode Exit fullscreen mode

Instead of passing the props we had statically before. We define a describing function on how to resolve the data, in our case by calling some api. As most functions do, they can accept some input to make it configurable, let's expose that by way of variables.

For the purposes of this post, use your imagination as to where to get those variables from, but could be something like useParams from your favourite routing library.

Our EntryPointContainer component also needs to be altered a little to handle this new fetch and variables properties.

const EntryPointContainer: SFC<{ entrypoint: EntryPoint<unknown, unknown> }> = ({
    entrypoint: {
        component: Component,
        fetch: fetchQuery,
        variables,
    },
}) => {
    const [props, setProps] = useState(null);

    useEffect(() => {
        fetchQuery(variables)
            .then(props => {
                setProps(props);
            });
    }, [fetch, variables]);

    if (props === null) return null;

    return <Component {...props} />;
};
Enter fullscreen mode Exit fullscreen mode

Simple stuff, a useEffect2 to call our fetch function and only rendering the Component once that data comes back.

... and the usage stays the same!

<EntryPointContainer entrypoint={HowdyEntryPoint} />
Enter fullscreen mode Exit fullscreen mode

We can actually go one step further. We all use GraphQL around these parts. So instead of passing a fetch function, let's describe the data by way of a GraphQL query 🦸‍♂️.

type EntryPoint<Variables, Props> = {
    component: ComponentType<Props>,
    query: string,
    variables: Variables
};

const HowdyEntryPoint: EntryPoint<{ userId: string }, Props> = {
    component: () => import('./howdy'),
    query: /* GraphQL */`query($id: ID) { user(id: $id) { name }}`,
    variables: { userId: 2 },
};
Enter fullscreen mode Exit fullscreen mode

Necessary encapsulation

What we did just then was describe the data dependency as something high-level, a GraphQL query. Which I cannot overstate as quite pivotal moment in our understanding of entry points.

We've moved the platform layer, the fetch function into a describing factor, leaving our platform engineers free to enact that fetch logic in our behalf, and at their pace.

I said before "a function describes how data is resolved, not it's result", but the problem with functions is they are heavy — often coupled to some network layer, so carries too much definition.

EntryPoints describe the ui dependency

Great! Our entry points can now mount and data is described.

But hang on... We've still got a synchronous bundle of the code. There is probably an entire article for this moment entirely.

If we continue down this notion of entry points being describing containers, we need to describe our component then as well — it's still the data not describing the data.

So let's fix that...

And what better way to do this than with our trusty esm import functions.

type EntryPoint<Variables, Props> = {
    component: () => Promise<ComponentType<Props>>,
    query: string,
    variables: Variables
};

const EntryPointContainer: SFC<{ entrypoint: EntryPoint<unknown, unknown> }> = ({
    entrypoint: {
        component,
        query,
        variables,
    },
}) => {
    const [props, setProps] = useState(null);
    const [Component, setComponent] = useState(null);

    useEffect(() => {
        fetchQuery(query, variables)
            .then(props => {
                setProps(props);
            });
    }, [query, variables]);

    useEffect(() => {
        component()
            .then(Component => {
                setComponent(Component);
            });
    }, [component]);

    if (props === null || Component === null) return null;

    return <Component {...props} />;
};
Enter fullscreen mode Exit fullscreen mode

... the component and the data is both split away, creating a thin json serialisable3 definition of how to paint this entry point 🦄.

Need to quickly fix our HowdyEntryPoint to use these new properties:

const HowdyEntryPoint: EntryPoint<{ userId: string }, Props> = {
    component: () => import('./howdy'),
    query: /* GraphQL */`query($id: ID) { user(id: $id) { name }}`,
    variables: { userId: 2 },
};
Enter fullscreen mode Exit fullscreen mode

... and it all still renders the same!

✨ You've made it! Congratulations 🎉, you have built Relay Entry Points!

We've taken what was "code" into what is now a "description"!

There's just one thing... Great! We've moved the data out of React and how, but why?

Why data doesn't belong in React

If we switch gears to looking at this question from the point of view of a server needing to prepare the data required for a page.

If all the data was in-react (noting it's not static as mentioned before), how would it know what data to go and prepare? We'd need to render the entire React tree to discover these things, which is quite a costly endeavour.

There's an entire article on this topic, and how routing might work. But to help me with this article, let's just say routes point to entry points. So when the server receives a request for a route, we can look at all our entry points, grab the one that matched.

So we have static/instant access to the data requirements — and how to get it.

That's it, that's why! Gee Marais, took you long enough!

Let's continue looking at how we can solve this. The clever ones might have realised, our application topology originally described global navs, main contents, project headers etc.. If those are all "Entry Points" we composed.

We'd get some pretty nasty waterfall loadings 😭, so let's fix that!

EntryPoints describe the rendering and loading

We're getting into the weeds now with respects to resource loading, and Suspense have a gander probably firstly. Super tl;dr — suspense is a way for React to handle promises for us.

In the first example we had our data right there available, 👋 mona. All we needed was { props: { data } }, and done. Now we've got this intermediate loading state, api shenanigans to deal with.

Wouldn't it be nice if we could take our definitions of entry points, and frame them back into a form where the data was static.

Let's try!

What immediately comes to mind is loading the data before we render:

// Something suspensey
type PreloadedEntryPoint<Data> = { ... };

const loadEntryPoint = <Variables, Props>(
    entrypoint: EntryPoint<Variables, Props>,
    variables: Variables,
): Promise<PreloadedEntryPoint<Props>> => { ... };

const EntryPointContainer: SFC<{ entrypoint: PreloadedEntryPoint<unknown> }> = ({
    entrypoint,
}) => {
    const { Component, props } = entrypoint.read(); // suspends

    return <Component {...props} />;
};

loadEntryPoint(HowdyEntryPoint)
    .then(entrypoint => {
        ReactDOM.render(<EntryPointContainer entrypoint={entrypoint} />);
    });
Enter fullscreen mode Exit fullscreen mode

Much like our type-safe dependency we created with our Entry Point. We've created another layer of type-safety, joining an in-flight or preloaded type-safe container of the data to the entry point. This ensures we are passing around right preloaded data to the right component.

Now, you're thinking. We have to be explicitly be passing around these preloaded data containers, and wondering why.

It's actually a really good thing. If it quack like a duck, swims like a duck then call it a duck. It makes it quite clear who needs it, whose using it and of course when nobody uses anymore, it's safe to delete.

Our component doesn't need the definition of the data, it needs the data itself! So from the components point of view it effetely voices "hey I need this preloaded data", which answers the "who needs it" question.

The "who is using it" question, is tracked by way of passing that into the EntryPointContainer component. We're not going to get into Relay's concept of reference counting, but the idea is when the preloaded data is no longer used, we can omit this from our memory. Because it's safe to do. For if we need it again, we know how to get it again.

... and bam! You've achieved the Relay definition of entry points.

Let's see one, and build GitHub!

EntryPoints build GitHub4

As much as we loved our Howdy component, let's define something real like you'd expect to see.

ProjectHeader
const ProjectHeader: SFC<{
    queries: {
        queryRef: PreloadedQuery<typeof ProjectHeaderQuery>
    }
}> = ({ queries }) => {
    const data = usePreloadedQuery(graphql`query ProjectHeaderQuery($owner: String, $repo: String) {
        repository(owner: $owner, name: $repo) {
            owner
            name
            stars
        }
    }`, queries.queryRef);

    return <div>
        <h1>{data.repository.owner}/{data.repository.name}</h1>
        <button>Stars {data.repository.stars}</button>
    </div>;
};

const ProjectHeaderEntryPoint: EntryPoint<{
    owner: string,
    repo: string
}> = {
    root: JSResource('ProjectHeader'),
    getPreloadedProps(params) {
        return {
            queries: {
                queryRef: {
                    parameters: ProjectHeaderQuery,
                    variables: {
                        owner: params.owner,
                        user: params.repo,
                    },
                },
            },
        };
    },
};
Enter fullscreen mode Exit fullscreen mode

RepositoryView
const RepositoryView: SFC<{
    queries: {
        queryRef: PreloadedQuery<typeof RepositoryViewQuery>
    },
    entryPoints: {
        projectHeader: typeof ProjectHeaderPoint
    }
}> = ({ queries, entrypoints }) => {
    const data = usePreloadedQuery(graphql`query RepositoryViewQuery($owner: String, $repo: String) {
        repository(owner: $owner, name: $repo) {
            readme {
                html
            }
        }
    }`, queries.queryRef);

    return <div>
        <EntryPointContainer entrypoint={entrypoints.projectHeader}/>

        <div>
            <h2>Readme</h2>
            <div dangerouslySetInnerHTML={{ __html: data.repository.readme.html }}/>
        </div>
    </div>;
};

const RepositoryViewEntryPoint: EntryPoint<{
    owner: string,
    repo: string
}> = {
    root: JSResource('RepositoryView'),
    getPreloadedProps(params) {
        return {
            queries: {
                queryRef: {
                    parameters: RepositoryViewQuery,
                    variables: {
                        owner: params.owner,
                        user: params.repo,
                    },
                },
            },
            entryPoints: {
                projectHeader: ProjectHeaderEntryPoint,
            },
        };
    },
};
Enter fullscreen mode Exit fullscreen mode

Have a read of those, but our app would compose them into something like this:

Please don't crucify me, please do handle errors, edge cases, etc.. It's pseudo code at best.

let routes = {
    '/:owner/:repo': RepositoryViewEntryPoint,
};

const matchRoute = (url: string) => routes[url];

const initialPage = loadEntryPoint(matchRoute(location.href));

const App = () => {
    const { entrypoint, setEntryPoint } = useState(initialPage);

    useEffect(() => {
        // Please use something like https://github.com/lukeed/navaid
        window.addEventListener('pushstate', () => {
            setEntryPoint(matchRoute(location.href));
        });
    }, []);

    return <Suspense fallback={null}>
        <EntryPointContainer entrypoint={entrypoint}/>
    </Suspense>;
};
Enter fullscreen mode Exit fullscreen mode

Wowzers! EntryPoints can compose other EntryPoints!!?!?!

Our project header is composed by the repository view (or page or layout), similar to the Outlet concept.

Relay when that top level EntryPoint is loadEntrypointed, will recursively call the getPreloadedProps, and all the data and code fetchers will all run in parallel.

Modals

... or really anything behind a user interaction — is an EntryPoint.

Seeing as the "building block" is described as an entry point we can preload, or deferred load this behind user interaction.

Like say the GitHub "code fly out", the fly out there requires — the users codespaces, the ssh or html preference, and potentially all sorts of other ui and data, which is not required for the critical load.

We can then declare this as an EntryPoint like so:

const CodeFlyout: SFC<{
    queries: {
        queryRef: PreloadedQuery<typeof CodeFlyoutQuery>
    }
}> = ({ queries }) => {
    const data = usePreloadedQuery(graphql`query CodeFlyoutQuery($owner: String, $repo: String) {
        repository(owner: $owner, name: $repo) {
            url {
                ssh
                https
            }

            codespaces {
                name
                url
            }
        }

        viewer {
            cloning_preference
        }
    }`, queries.queryRef);

    return (<div>
        <Tabs active={data.viewer.cloning_preference}>
            <Item name="ssh">
                <pre>{data.repository.url.ssh}</pre>
            </Item>
            <Item name="https">
                <pre>{data.repository.url.https}</pre>
            </Item>
        </Tabs>

        <p>Codespaces is awesome, you should use it</p>
        {data.repository.codespaces.map(item => (
            <a href={item.url}>Open codespace {item.name}</a>
        ))}
    </div>);
};

const CodeFlyoutEntryPoint: EntryPoint<{
    owner: string,
    repo: string
}> = {
    root: JSResource('CodeFlyout'),
    getPreloadedProps(params) {
        return {
            queries: {
                queryRef: {
                    parameters: CodeFlyoutQuery,
                    variables: {
                        owner: params.owner,
                        user: params.repo,
                    },
                },
            },
        };
    },
};

const RepositoryView = () => {
    return (<div>
        { /* all the other stuff from above */}

        <FlyoutTrigger entrypoint={CodeFlyoutEntryPoint}>
            {({ onClick }) =>
                (<button onClick={onClick}>Code</button>)
            }
        </FlyoutTrigger>
    </div>);
};
Enter fullscreen mode Exit fullscreen mode

Just wonderful, we've declaratively composed what our page needs, its all feeling great from a UX point of view. The bits that sit behind user interaction are code-split and everything is great! And best of all, its type-safe through and through!!!

But really skies the limit now in how you use it!

  • you could preload the entry point on hover
  • you could intersection observer to check that all visible ModalTrigers have their entry points preloaded

EntryPoints can protect your routes

Note that routes object above can come from a window object, incrementally hydrated from an api or whatever — its just json.

A side moment, and something important ☝️.

To handle permissions, read access, and discoverability of routes. You may not want to flush your entire entry point map to the client. But instead before a navigation to a route occurs, you ask the server for the entry point json - or not return anything for like a 404.

You could do something like:

useEffect(() => {
    window.addEventListener('pushstate', () => {
        const target = location.href;
        fetch(`/routes?to=${target}`)
            .then(route => {
                if (route) {
                    Object.assign(routes, route);
                    setEntryPoint(matchRoute(target));
                } else {
                    setEntryPoint(matchRoute('404'));
                }
            });
    });
}, []);
Enter fullscreen mode Exit fullscreen mode

... please write something better than that, but the idea is. Either on hover, or on click — first ask your well protected backend what the entry point is to power that route.

If it returns nothing, then 404. If it returns, go for it. Meaning all the "this user can access it" etc can all be surfaced hiding all the usual, "the route exists but the user can't see it" security risks.

Think like a private repo, if the entry point exists, and was attempted, then maybe you can use that to try other things.


Summary

Let's quickly recap what we've achieved and make sure you've grasped the main points.

  1. entry points are thin json serializable definitions of, what code to run, and the data that code may need.
  2. entry points describe the data dependency, not the data itself.
  3. entry points describe the code dependency.
  4. entry points are type-safe and statically analysable.
  5. entry points are loaded and handled outside the react life cycle.
  6. entry points should wrap things that sit behind user interaction, route transitions are behind user interaction.

Read More

What is JSResource?

Quite simply just function that returns a suspenseful wrapper around a promise. Remember before when I said entry points are json serialisable, well this is how. JSResource under the hood, would be going import('./components/${name}'). Or however you wish to resolve it.

Sample implementation 👉 npm jsr

Thanks

Special thanks to Tom Gasson for article inspiration ❤️

Cover photo by Ivan Aleksic


Follow me on twitter ~> @slightlycode


  1. No not the John Cleese Royal Society For Putting Things On Top of Other Things because that would be rather silly. 

  2. Don't use this in production for reasons, and for things error boundaries, loading states and so forth. 

  3. Just need to move our async import into a string that is looked-up/fetched similar to how the query is. JSResource will be your friend. 

  4. None of this is actually how GitHub is built, nor endorsed or sponsored by them. 

Top comments (0)