Skip to main content
Version: Canary

Cells

Cells are a declarative approach to data fetching and one of Cedar's signature modes of abstraction. By providing conventions around data fetching, Cedar can get in between the request and the response to do things like query optimization and more, all without you ever having to change your code.

While it might seem like there's a lot of magic involved, all a Cell really does is execute a GraphQL query and manage its lifecycle. The idea is that, by exporting named constants that declare what you want your UI to look like throughout a query's lifecycle, Cedar can assemble these into a component template at build-time using a Babel plugin. All without you having to write a single line of imperative code!

Generating a Cell

You can generate a Cell with Cedar's Cell generator:

yarn cedar generate cell <name>

This creates a directory named <name>Cell in web/src/components with four files:

FileDescription
<name>Cell.jsThe actual Cell
<name>Cell.test.jsJest tests for each state of the Cell
<name>Cell.stories.jsStorybook stories for each state of the Cell
<name>Cell.mock.jsMock data for both the Jest tests and Storybook stories

Single Item Cell vs List Cell

Sometimes you want a Cell that renders a single item and other times you want a Cell that renders a list. Cedar's Cell generator can do both.

First, it detects if <name> is singular or plural. For example, to generate a Cell that renders a list of users, run yarn cedar generate cell users. Second, for irregular words whose singular and plural are the same, such as "equipment" or "pokemon", you can pass --list to tell Cedar to generate a list Cell explicitly:

yarn cedar generate cell equipment --list

Cells in-depth

Cells exports five constants: QUERY, Loading , Empty , Failure and Success. The root query in QUERY is the same as <name> so that, if you're generating a cell based on a model in your schema.prisma, you can get something out of the database right away. But there's a good chance you won't generate your Cell this way, so if you need to, make sure to change the root query. See the Cells section of the Tutorial for a great example of this.

Usage

With Cells, you have a total of seven exports to work with:

NameTypeDescription
QUERYstring,functionThe query to execute
beforeQueryfunctionLifecycle hook; prepares variables and options for the query
isEmptyfunctionLifecycle hook; decides if the Cell should render Empty
afterQueryfunctionLifecycle hook; sanitizes data returned from the query
LoadingcomponentIf the request is in flight, render this component
EmptycomponentIf there's no data (null or []), render this component
FailurecomponentIf something went wrong, render this component
SuccesscomponentIf the data has loaded, render this component

Only QUERY and Success are required. If you don't export Empty, empty results are sent to Success, and if you don't export Failure, error is output to the console.

In addition to displaying the right component at the right time, Cells also funnel the right props to the right component. Loading, Empty, Failure, and Success all have access to the props passed down from the Cell in good ol' React fashion, and most of the useQuery hook's return as a prop called queryResult. In addition to all those props, Empty and Success also get the data returned from the query and an updating prop indicating whether the Cell is currently fetching new data. Failure also gets updating and exclusive access to error and errorCode.

We mentioned above that Cells receive "most" of what's returned from the useQuery hook. You can see exactly what useQuery returns in Apollo Client's API reference. Again note that error and data get some special treatment.

QUERY

QUERY can be a string or a function. If QUERY is a function, it has to return a valid GraphQL document.

It's more-than ok to have more than one root query. Here's an example:

export const QUERY = gql`
query {
posts {
id
title
}
authors {
id
name
}
}
`

So in this case, both posts and authors would be available to Success:

export const Success = ({ posts, authors }) => {
// ...
}

Normally queries have variables. Cells are setup to use any props they receive from their parent as variables (things are setup this way in beforeQuery). For example, here BlogPostCell takes a prop, numberToShow, so numberToShow is just available to your QUERY:

import BlogPostsCell from 'src/components/BlogPostsCell'

const HomePage = () => {
return (
<div>
<h1>Home</h1>
<BlogPostsCell numberToShow={3} />
</div>
)
}

export default HomePage
export const QUERY = gql`
query ($numberToShow: Int!) {
posts(numberToShow: $numberToShow) {
id
title
}
}
`

This means you can think backwards about your Cell's props from your SDL: whatever the variables in your SDL are, that's what your Cell's props should be.

beforeQuery

beforeQuery is a lifecycle hook. The best way to think about it is as a chance to configure Apollo Client's useQuery hook.

You can scaffold a typed beforeQuery stub with the cell generator's --before-query flag (isEmpty and afterQuery have --is-empty and --after-query equivalents):

yarn cedar generate cell Post --before-query

By default, beforeQuery gives any props passed from the parent component to QUERY so that they're available as variables for it. It'll also set the fetch policy to 'cache-and-network' since we felt it matched the behavior users want most of the time:

export const beforeQuery = (props) => {
return {
variables: props,
fetchPolicy: 'cache-and-network',
}
}

For example, if you wanted to turn on Apollo's polling option, and prevent caching, you could export something like this (see Apollo's docs on polling and caching)

export const beforeQuery = (props) => {
return { variables: props, fetchPolicy: 'no-cache', pollInterval: 2500 }
}

You can also use beforeQuery to populate variables with data not included in the Cell's props (like from React's Context API or a global state management library). If you provide a beforeQuery function, the Cell will automatically change the type of its props to match the first argument of the function.

// The Cell will take no props: <Cell />
export const beforeQuery = () => {
const { currentUser } = useAuth()

return {
variables: { userId: currentUser.id },
}
}
// The cell will take 1 prop named "word": <Cell word="abc">
export const beforeQuery = ({ word }) => {
return {
variables: { magicWord: word },
}
}

Skipping the query

Sometimes you don't want a Cell's query to run at all—maybe a value it depends on isn't available yet. Say you have a search page where SearchResultsCell takes the search term as a prop: until the user has typed something, there's nothing to search for. Most of the time the idiomatic solution is to not render the Cell until you have what you need:

const SearchPage = () => {
const [searchTerm, setSearchTerm] = useState('')

return (
<>
<SearchInput value={searchTerm} onChange={setSearchTerm} />
{searchTerm ? (
<SearchResultsCell searchTerm={searchTerm} />
) : (
<p>Start typing to search</p>
)}
</>
)
}

This keeps the Cell's contract simple (a rendered Cell always shows one of Loading, Empty, Failure or Success), and TypeScript checks the Cell's props at the call site with no extra work.

But sometimes the condition is the Cell's own business rather than the parent's. Maybe the query depends on a value from a global store, or should only run when there's a signed-in user. The parent could check that too, but then every place that renders the Cell has to duplicate the check. To keep the condition in the Cell itself, return Apollo's skipToken from beforeQuery instead of an options object:

import { skipToken } from '@apollo/client/react'

export const beforeQuery = ({ id }) => {
const otherId = useStore((state) => state.getOther(id))

return otherId ? { variables: { id, otherId } } : skipToken
}

A skipped Cell renders nothing at all (neither Loading, Empty, Failure nor Success) just as if the parent hadn't rendered it.

You can also skip by returning Apollo's skip option ({ variables, skip: true }), but prefer skipToken: with skip you still have to supply fully typed variables even though the query won't run, which tends to force non-null assertions like id!. With skipToken each branch of the ternary type-checks on its own. (Apollo recommends skipToken for the same reason.) In TypeScript, you can annotate beforeQuery's return type with CellBeforeQueryResult<YourQueryVariables> from @cedarjs/web to get full type checking of your variables.

Finally, if you want to keep showing the previous result instead of rendering nothing, like keeping the last search results on screen while the user clears the search box, don't skip. Render the Cell unconditionally, freeze the variables at their last valid value, and let the cache serve the stale data:

export const beforeQuery = ({ searchTerm }) => {
const lastSearchTerm = useRef(searchTerm)

if (searchTerm) {
lastSearchTerm.current = searchTerm
}

if (searchTerm) {
return { variables: { searchTerm } }
} else if (lastSearchTerm.current) {
// Serve the last result from the cache without hitting the network
return {
variables: { searchTerm: lastSearchTerm.current },
fetchPolicy: 'cache-first',
}
}

return skipToken
}

isEmpty

isEmpty is an optional lifecycle hook. It returns a boolean to indicate if the Cell should render empty. Use it to override the default check, which checks if the Cell's root fields are null or empty arrays.

It receives two parameters: 1) the data, and 2) an object that has the default isEmpty function, named isDataEmpty, so that you can extend the default:

export const isEmpty = (data, { isDataEmpty }) => {
return isDataEmpty(data) || data?.blog?.status === 'hidden'
}

afterQuery

afterQuery is a lifecycle hook. It runs just before data gets to Success. Use it to sanitize data returned from QUERY before it gets there.

By default, afterQuery just returns the data as it is:

Loading

If there's no cached data and the request is in flight, a Cell renders Loading.

When you're developing locally, you can catch your Cell waiting to hear back for a moment if set your speed in the Inspector's Network tab to something like "Slow 3G".

But why bother with Slow 3G when Cedar comes with Storybook? Storybook makes developing components like Loading (and Failure) a breeze. We don't have to put up with hacky workarounds like Slow 3G or intentionally breaking our app just to develop our components.

Empty

A Cell renders this component if there's no data. By no data, we mean if the response is 1) null or 2) an empty array ([]).

Failure

A Cell renders this component if something went wrong. You can quickly see this in action if you add an untyped field to your QUERY:

const QUERY = gql`
query {
posts {
id
title
unTypedField
}
}
`

But, like Loading, Storybook is probably a better place to develop this.

In this example, we use the errorCode to conditionally render the error heading title, and we also use it for our translation string.

export const Failure = ({ error, errorCode }) => {
const { t } = useTranslation()
return (
<div style={{ color: 'red' }}>
{errorCode === 'NO_CONFIG' ? <h1>NO_CONFIG</h1> : <h1>ERROR</h1>}
Error: {error.message} - Code: {errorCode} - {t(`error.${errorCode}`)}
</div>
)
}

Success

If everything went well, a Cell renders Success.

As mentioned, Success gets exclusive access to the data prop. But if you try to destructure it from props, you'll notice that it doesn't exist. This is because Cedar adds a layer of convenience: Cedar spreads data into Success so that you can just destructure whatever data you were expecting from your QUERY directly.

So, if you're querying for posts and authors, instead of doing:

export const Success = ({ data }) => {
const { posts, authors } = data

// ...
}

Cedar lets you do:

export const Success = ({ posts, authors }) => {
// ...
}

Note that you can still pass any other props to Success. After all, it's just a React component.

tip

Looking for info on how TypeScript works with Cells? Check out the Utility Types doc.

When should I use a Cell?

Whenever you want to fetch data. Let Cedar juggle what's displayed when. You just focus on what those things should look like.

While you can use a Cell whenever you want to fetch data, it's important to note that you don't have to. You can do anything you want! For example, for one-off queries, there's always useApolloClient. This hook returns the client, which you can use to execute queries, among other things:

// In a react component...

client = useApolloClient()

client.query({
query: gql`
...
`,
})

Can I Perform a Mutation in a Cell?

Absolutely. We do so in our example todo app. We also don't think it's an anti-pattern to do so. Far from it—your cells might end up containing a lot of logic and really serve as the hub of your app in many ways.

It's also important to remember that, besides exporting certain things with certain names, there aren't many rules around Cells—everything you can do in a regular component still goes.

Cell Types Reference

All Cell-related types are exported from @cedarjs/web and share the Cell prefix. A nice side effect of the shared prefix is that typing Cell inside an import type { ... } from '@cedarjs/web' statement makes your editor's autocomplete list the whole family.

TypeWhat it types
CellSuccessProps<TData, TVariables>Props of the Success and Empty components: the query's data (spread as individual props), plus queryResult and updating
CellFailureProps<TVariables>Props of the Failure component: error, errorCode, queryResult and updating
CellLoadingProps<TVariables>Props of the Loading component
CellSuccessData<TData>Just the data part of CellSuccessProps, useful when passing the query result on to other functions or components
CellBeforeQueryResult<TVariables>Return type of beforeQuery: the query options, or skipToken to skip the query
CellBeforeQueryOptions<TVariables>The options object variant of CellBeforeQueryResult: variables plus any other option Apollo Client's useQuery hook accepts
TypedDocumentNode<TData, TVariables>The QUERY document. Annotating QUERY with it flows the query's data and variable types through to the rest of the Cell
CellProps<...>The complete prop interface of a Cell as seen by the components rendering it. Used by Cedar's generated types—you'll rarely write it yourself

For worked examples of CellSuccessProps, CellFailureProps, and CellLoadingProps, see the Utility Types doc. For CellBeforeQueryResult, see the beforeQuery section above.

Fragment Cells: Aggregating Queries

Nesting a Cell inside another Cell's Success component works, but it creates a request waterfall: the parent Cell has to finish its query before the child Cell can even start its own. For a Cell that only ever renders a slice of its parent's data, you can avoid the second request entirely by exporting a FRAGMENT instead of a QUERY:

web/src/components/AuthorCell/AuthorCell.jsx
export const FRAGMENT = gql`
fragment AuthorCell_author on User {
id
email
fullName
}
`

export const Success = ({ author }) => (
<span>
{author.fullName} ({author.email})
</span>
)

The parent Cell spreads the fragment in its QUERY and passes the matching data object down as a prop named after the fragment — AuthorCell_author means the Cell takes (and Success receives) an author prop:

web/src/components/BlogPostCell/BlogPostCell.jsx
import AuthorCell from 'src/components/AuthorCell'

export const QUERY = gql`
query FindBlogPostQuery($id: Int!) {
post(id: $id) {
id
title
body
author {
...AuthorCell_author
}
}
}
`

export const Success = ({ post }) => (
<article>
<h2>{post.title}</h2>
<AuthorCell author={post.author} />
<div>{post.body}</div>
</article>
)

One network request fetches everything. You don't have to import or interpolate the fragment into the parent's QUERY — fragment Cells automatically register their fragment with the GraphQL client, so spreading it by name is enough (importing the child Cell to render it is what pulls the fragment in).

A few things to know about fragment Cells:

  • The data prop's name is derived from the fragment name: for a name with an underscore, like AuthorCell_author, it's the part after the last underscore (author). Otherwise it's the camelCased name of the type the fragment is defined on (on User becomes user). The parent passes the data in with that prop, and Success (and Empty) receive the data as that same prop.
  • In TypeScript projects, a type named exactly like the fragment (AuthorCell_author in the example above) is generated in types/graphql, and the Cell's data prop is typed with it. So if the parent's QUERY is missing the fragment spread (or the spread doesn't cover all the fields), you'll get a TypeScript error right where the fragment Cell is rendered.
  • There's no Loading or Failure state. The data is read synchronously from the parent's query result, so by the time a fragment Cell renders, the data is already there. Empty, isEmpty, and afterQuery work like they do in regular Cells, and a null data prop (a nullable field, or a partial error when the parent uses errorPolicy: 'all') renders Empty.
  • Include the type's id in the fragment. With it, the Cell reads its data live from the GraphQL client's cache, so it automatically re-renders when a mutation or another query updates that entity. Without it, the Cell still renders (it falls back to the data object it was passed), but it only updates when the parent re-renders.
  • This works for lists too: a parent Cell that fetches posts { author { ...AuthorCell_author } } can render one <AuthorCell author={post.author} /> per post, still with a single request.

How Does Cedar Know a Cell is a Cell?

You just have to end a filename in "Cell" right? Well, while that's basically correct, there is one other thing you should know.

Cedar looks for all files ending in "Cell" (so if you want your component to be a Cell, its filename does have to end in "Cell"), but if the file 1) doesn't export a const named QUERY (or FRAGMENT) and 2) has a default export, then it'll be skipped.

When would you want to do this? If you just want a file to end in "Cell" for some reason. Otherwise, don't worry about it!

Advanced Example: Implementing a Cell Yourself

If we didn't do all that build-time stuff for you, how might you go about implementing a Cell yourself?

Consider the example from the Tutorial where we're fetching posts:

export const QUERY = gql`
query FindPosts {
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 key={post.id}>
<h2>{post.title}</h2>
<div>{post.body}</div>
</article>
))
}

And now let's say that Babel isn't going to come along and assemble our exports. What might we do?

We'd probably do something like this:

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

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

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

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

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

const isEmpty = (data) => {
return isDataNull(data) || isDataEmptyArray(data)
}

export const Cell = () => {
return (
<Query query={QUERY}>
{({ error, loading, data }) => {
if (error) {
if (Failure) {
return <Failure error={error} />
} else {
console.error(error)
}
} else if (loading) {
return <Loading />
} else if (data) {
if (typeof Empty !== 'undefined' && isEmpty(data)) {
return <Empty />
} else {
return <Success {...data} />
}
} else {
throw 'Cannot render Cell: graphQL success but `data` is null'
}
}}
</Query>
)
}

That's a lot of code. A lot of imperative code too.

We're basically just dumping the contents of createCell.tsx into this file. Can you imagine having to do this every time you wanted to fetch data that might be delayed in responding? Yikes.