React Single File Components Are Here

React has long eschewed convention in favor of the extreme flexibility of JS. It is time for the next level in React authorship formats.

react Posted:

The launch of RedwoodJS today marks a first: it is the first time React components are being expressed in a single file format with explicit conventions.

I first talked about this in relation to my post on React Distros, but I think React SFCs will reach standardization in the near future and it is worth discussing now as a standalone post.

Context

It is a common joke that you can't get through a React conference without reference to React as making your view layer a pure function of data, v = f(d). I've talked before about the problems with this, but basically React has always done a collective ¯\_(ツ)_/¯ when it comes to having a satisfying solution for actually fetching that all-important data. React Suspense for Data Fetching may be a nice solution for this in the near future.

Here's a typical React "single file component" with Apollo Client, though it is the same with React Query or any data fetching paradigm I can imagine:

// Data Example 1
export const QUERY = gql`
  query {
    posts {
      id
      title
      body
      createdAt
    }
  }
`;

export default function MyComponent() {
  const { loading, error, data: posts } = useQuery(QUERY);
  if (error) return <div>Error loading posts: {error.message}</div>
  if (loading) return <div>Loading...</div>;
  if (!posts.length) return <div>No posts yet!</div>;

  return (<>
    posts.map(post => (
      <article>
        <h2>{post.title}</h2>
        <div>{post.body}</div>
      </article>
    ));
  </>)
}

For styling, we might use anything from Tailwind to Styled-JSX to Styled-Components/Emotion to Linaria/Astroturf to CSS Modules/PostCSS/SASS/etc and it is a confusing exhausting random eclectic mix of stuff that makes many experts happy and many beginners lost. But we'll talk styling later.

Redwood Cells

Here's what Redwood Cells look like:

// Data Example 2
export const QUERY = gql`
  query {
    posts {
      id
      title
      body
      createdAt
    }
  }
`;

export const Loading = () => <div>Loading...</div>;

export const Empty = () => <div>No posts yet!</div>;

export const Failure = ({ error }) => <div>Error loading posts: {error.message}</div>;

export const Success = ({ posts }) => {
  return posts.map(post => (
    <article>
      <h2>{post.title}</h2>
      <div>{post.body}</div>
    </article>
  ));
};

This does the same thing as the "SFC" example above, except instead of writing a bunch of if statements, we are baking in some conventions to make things more declarative. Notice in both examples are already breaking out the GraphQL queries, so that they can be statically consumed by the Relay Compiler, for example, for persisted queries.

This is a format that is more native to React's paradigms than the Single File Component formats of Vue, Svelte, and my own React SFC proposal a year ago, and I like it a lot.

Notice that, unlike the HTML-inspired formats of the above options, we don't actually lose the ability to declare smaller utility components within the same file, since we can just use them inline without exporting them. This has been a major objection of React devs for SFCs in the past.

Why Formats over Functions are better

As you can see, the authoring experience between the Example 1 and Example 2 is rather nuanced, and to articulate this better, I have started calling this idea Formats over Functions.

The idea is that you don't actually need to evaluate the entire component's code and mock the data in order to access one little thing. In this way you bet on JS more than you bet on React itself.

This makes your components more consumable and statically analyzable by different toolchains, for example, by Storybook.

Component Story Format

Team Storybook was actually first to this idea and a major inspiration for me, with it's Component Story Format. Here's how stories used to be written:

// Storybook Example 1
import React from 'react';
import { storiesOf } from '@storybook/react';
import { Button } from '@storybook/react/demo';

storiesOf('Button', module)
 .addWithJSX(
   'with Text',
   () => <Button>Hello Button</Button>
 )
 .add('with Emoji', () => (
  <Button>
    <span role="img" aria-label="so cool">
      😀 😎 👍 💯
    </span>
  </Button>
 ));

You needed to have Storybook installed to make use of this, and if you ever needed to migrate off Storybook to a competitor, or to reuse these components for tests (I have been in this exact scenario), you were kind of screwed.

Component Story Format (CSF) lets you write your components like this:

// Storybook Example 2
import React from 'react';
import { Button } from '@storybook/react/demo';

export default { title: 'Button' };

export const withText = () => <Button>Hello Button</Button>;

export const withEmoji = () => (
  <Button>
    <span role="img" aria-label="so cool">
      😀 😎 👍 💯
    </span>
  </Button>
);

and all of a sudden you can consume these files in a lot more different ways by different toolchains (including by design tools!) and none of them have to use Storybook's code, because all they need to know is the spec of the format and how to parse JavaScript. (yes, JSX compiles to React.createElement, but that is easily mockable).

Merging CSF and SFCs

You're probably already seeing the similarities - why are we authoring stories and components separately? Let's just stick them together?

You already can:

// CSF + SFC Example
export default { 
  title: 'PostList',
  excludeStories: ['QUERY']
};

export const QUERY = gql`
  query {
    posts {
      id
      title
      body
      createdAt
    }
  }
`;

export const Loading = () => <div>Loading...</div>;

export const Empty = () => <div>No posts yet!</div>;

export const Failure = ({ error }) => <div>Error loading posts: {error.message}</div>;

export const Success = ({ posts }) => {
  return posts.map(post => (
    <article>
      <h2>{post.title}</h2>
      <div>{post.body}</div>
    </article>
  ));
};

Adding TypeScript would take no change in tooling at all.

And therein lies the beauty of the format, and the impending necessity of standardizing exports for fear of stepping on each others' toes as we push forward React developer experience.

The Full Potential of Single File Components

I think Styling is the last major frontier we need to integrate. Utility CSS approaches aside, here's how we can include static scoped styles for our components:

// Styled SFC - Static Example
export const STYLE = `
  /* only applies to this component */
  h2 {
    color: red
  }
`
export const Success = ({ posts }) => {
  return posts.map(post => (
    <article>
      <h2>{post.title}</h2>
      <div>{post.body}</div>
    </article>
  ));
};

// etc...

and, if we needed dynamic styles, the upgrade path would be fairly simple:

// Styled SFC - Dynamic Example
export const STYLE = props => `
  h2 {
    color: ${props.color} // dynamic!
  }
`
// etc...

And that would upgrade to a CSS-in-JS equivalent implementation.

Interacting with Hooks

What if styles or other future Single File Component segments need to interact with component state? We could lift hooks up to the module level:

// Hooks SFC Example
const [toggle, setToggle] = useState(false)

export const STYLE = `
  h2 {
    color: ${toggle ? "red" : "blue"}
  }
`
export const Success = ({ posts }) => {
  return posts.map(post => (
    <article>
      <h2 onClick={() => setToggle(!toggle)}>{post.title}</h2>
      {toggle && <div>{post.body}</div>}
    </article>
  ));
};

This of course changes the degree of reliance on the React runtime that we assume in SFCs, so I am less confident about this idea, but I do still think it would be useful. I have other, more extreme ideas on this front.

Other Opportunities

Dan Abramov replied with something I missed - the server/client split. There is ongoing work with React Flight (to do with streaming SSR) and Blocks (to do with blocking rendering without being tied to Relay/GraphQL) that I'm basically completely ignorant about.

While Redwood uses exports to declare loading and error states, Suspense uses <Suspense> and error boundaries. It's possible to compile from the former to the latter but not the other way, which is a key point of the "formats over functions" idea - things are more consumable that way. However it is a valid question whether you should be able to access those internal states - after all, if you're just reading them for testing purposes, should you be testing how React works? Counterpoint: what if you wanted to see your loading and error states separately in a Storybook or design tool?

It also brings to mind the work that Next.js has done with getStaticProps, getStaticPaths and getServerSideProps - as the first hybrid framework, it is nice to use static exports to let the framework pick from data requirements, as well as to not tie yourself so tightly to GraphQL. getStaticPaths in particular is very elegant - moving page creation inside components themselves.

Conclusion - Ending with Why

It's reasonable to question why we want everything-in-one file rather than everything-in-a-folder. But in a sense, SFCs simply centralize what we already do with loaders.

Think about it: we often operate like this:

/components/myComponent/Component.tsx
/components/myComponent/Component.scss
/components/myComponent/Component.graphql
/components/myComponent/Component.stories.js
/components/myComponent/Component.test.js

And some people may think that is better than this:

/components/myComponent/Component.tsx
/styles/Component.scss
/graphql/Component.graphql
/stories/myComponent/Component.stories.js
/tests/myComponent/Component.test.js

But we're exchanging that for:

export default = // ... metadata
export const STYLE = // ...
export const QUERY = // ...
export const Success = // ...
export const Stories = // ...
export const Test = // ...

I find that the file length is mitigated by having keyboard shortcuts for folding/expanding code in IDE's. In VSCode, you can fold/unfold code with keyboard bindings:

Fold folds the innermost uncollapsed region at the cursor:

  • Ctrl + Shift + [ on Windows and Linux
  • ⌥ + ⌘ + [ on macOS

Unfold unfolds the collapsed region at the cursor:

  • Ctrl + Shift + ] on Windows and Linux
  • ⌥ + ⌘ + ] on macOS

Fold All folds all regions in the editor:

  • Ctrl + (K => 0) (zero) on Windows and Linux
  • ⌘ + (K => 0) (zero) on macOS

Unfold All unfolds all regions in the editor:

  • Ctrl + (K => J) on Windows and Linux
  • ⌘ + (K => J) on macOS

Ultimately, Colocating concerns rather than artificially separating them helps us delete and move them around easier, and that optimizes for change.

I have more thoughts on how we can apply twists of this ideahere on my old proposal.

This movement has been a long time coming, and I can see the momentum accelerating now.


Webmentions

See comments on Dev.to
Loading...