Cells and children

Hi,
am I mistaken or do Cells cannot take children?

I am wondering, why this is the case? Cells could be much more powerful with children. They would work similar to react suspense. Every component within a cell would know, it has its data.

It would allow me to use a cell i.e. within a layout and give all its subroutes the same data:

<Set wrap={TenantLayout}>
   <Route path={"/overview/{entry}"}.... />
   <Route path={"/settings"}.... />
   <Route path={"/project/{project}"}.... />
</Set>

This is how I would need to do it right now, losing the option to let children routes to know they have tenant data:

const TenantLayout = ({children}:PropsWithChildren) => {
   return <>
              <TenantCell />
              {children}
           </>
}

This is how I would like to do it:

const TenantLayout = ({children}:PropsWithChildren) => {
   return <TenantCell>
              {children}
           </TenantCell>
}

Because only through the latter approach, all routes within the layout would have access to the TenantCell data i.e. via useQuery hook. The latter could be easily wrapped via generators: Hooks as addition to Cells

I understand that cells are set up to be self contained. But this does not seem to be reaonsable in practical use right now.

Couldn´t we just pass the children prop of the above linked line 276 into the Success component?

I tried to bypass it with a custom renderChildren prop via beforeQuery, but this is definitely no solution, since the prop is being serialized for the query…

Related:

@dennemark Would you be interested in trying to make Redwood support children for Cells? If we get it working I think it can be pretty cool :slight_smile:

Going to have a look at it! I saw one issue, where children were discussed. I think I should start there, since there seemed to be reasons why Cells don´t support children right now. I think it had to do with useQuery.
Hope to find some time soon!

Awesome! Please keep me updated

I have to check in more detail tomorrow. I might have done a stupid mistake. Maybe all I needed, was add proper typing to Success:

export const Success = ({ tenant, children }: PropsWithChildren<CellSuccessProps<FindTenantById>>) => {

  return <>{children}</>//<Tenant tenant={tenant} />
}

The createCell file actually decomposes props into const {children: _, variables } = props for the queries. But Sucess still gets full {...props}. And tests also include children for Cells. So the comment within createCell might be misleading.

But it might be worth considering, only distributing children to the Success component. What do you think @Tobbe ? Maybe the Empty component can be considered, too.

If we’re going to officially support that it’d be nice to have a nicer way to pass data to the children. Like if you wanted the children to know about the tenant data and you had

<TenantCell>
  <Settings />
</TenantCell>

It would be nice if <Settings /> got called with <Settings tenant={tenant} /> somehow.

Right now if you do like this children still don’t know about tenant

export const Success = ({ tenant, children }: PropsWithChildren<CellSuccessProps<FindTenantById>>) => {
  return (
    <>{children}</>
  )
}

You’d have to do some crazy things with React.createElement (If you want to see what that could look like you can take a look at the Set.tsx code in the framework with how it injects wrapper props.)

An easy and React-standard way to do it is to just use a Context and store the cell data in the context and let the children read it from there

The createElement idea is nice, but limited only to the children of Success and not components within those. My current approach is the usage of useQuery. Here is an example where I also supplied useParams for convenience, so I just need to call the hook useTenant() and know I have the right data.

import { useParams } from '@redwoodjs/router'
import type { FindTenantById } from 'web/types/graphql'

import { useQuery } from '@redwoodjs/web'
import { QUERY } from 'web/src/components/Tenant/TenantCell/TenantCell'

export const useTenant = () => {
    const { tenant } = useParams()

  const query = useQuery<FindTenantById>(QUERY, { variables: { id: tenant } })
  return query.data?.tenant
}

Of course we could also do other things with useQuery, like listening if it succeeded.
I already asked how to integrate it into generators for cells: Hooks as addition to Cells - #5 by dennemark
(But currently I am having issues with typings in my mono repo, so I have to place the hook somewhere else)

Since I did not properly assign PropsWithChildren to a Cell in the beginning, I was not able to assign children to Cells. Now we know, that it already works and I am wondering if we need to change anything in the actual code.
I would still like to share how I use cells in my project now. Since it might be useful for others. It is a bit like the Suspense approach of React 18. With Loading, Empty and Failure Cells as fallbacks.

Example structure:

<Set wrap="TenantLayout">
          <Route path="/{tenant}/overview" page={OverviewPage} name="overview" />
          <Route path="/{tenant}/portfolio/{item}" page={PortfolioPage} name="portfolio" />
</Set>

Now TenantLayout includes the TenantCell, which should have Success component as mentioned above: Cells and children - #5 by dennemark . Since we route to paths include{tenant} we can get the param from url and supply it to the Cell. This would normally be more convenient via Routes.

const TenantLayout = ({children}:PropsWithChildren)=>{
 const {tenant} = useParams()
 return <div>
   <TenantCell>
      <Navbar />
      {children}
  </TenantCell>
</div>

Via the custom hook useTenant() from above (Cells and children - #8 by dennemark), we can now get tenant data in children of TenantLayout as well as Navbar.

PortfolioPage now might only look like this:

const PortfolioPage = ({item}: CellSuccessProps<FindItemById> =>{

return <PortfolioCell id={item} />
}

Or OverviewPage might not even need a cell:

const OverviewPage = ({item}: CellSuccessProps<FindItemById> =>{

const tenant = useTenant()

return <div>{tenant?.name}</div> /** tenant is not guaranteed to be defined **/
}

Of course we could also keep the cells within our routes, but each route would have to query tenant data.
Now if I would not have bit different requirements, I would keep the redwood approach, since its opinionated and structures the repository nicely. The above example is less structured and Cells are now in Layout or url params for cells not queried by routes. If one really wants to integrate this approach in redwood while keeping a nice structure, I guess the best approach would be, to allow nesting of routes, like other libraries are doing it. But to keep the flat routing structure with its naming (which is awesome), the nesting could be done via Sets: maybe Sets could have kind of a parentPath="/{tenant}" and the routes then only need path="/portfolio". This way, we could consume the tenant variable earlier. But I guess this would make the tooling more difficult.
Anyways, the above approch works :slight_smile:

Btw. here is a glimpse into our product: build.form – form follows you

It doesn’t sound like it’s been explored at least in this thread, but Fragments were created to solve the ‘how do I nest sub-queries’ in the language - maybe the solution to having Cell children is to allow for Fragment Cells.

Thanks for your input! Since GraphQL is still quite new to me, this is worth a read!
Definitely is an interesting idea to compose GraphQL to extend the data!

Hey denne,

Looking at your product reminded me of this: https://buildatmos.com/

It is not quite the same, but a similar line of business.

Thanks for the reference! :slight_smile:
I know some similar ones. Atmos seems to go more into construction than we do. But the field for city planning and architecture is still broad.
You bought a house at atmos? ;D

This has been mentioned before (a looong time ago). At that time it was to make CRUD routes less verbose. Thanks for bringing it up again to remind me about it :slight_smile:

2 Likes

@dennemark Another option I’ve found for decoupling the cell’s data from how it’s displayed is to use Render Props. I’ve used it in instances where I need to use the same data in a number of ways (like in a list and as options in a select dropdown).

In your TenantCell.tsx:

// ...
export type SuccessProps = CellSuccessProps<TenantQuery>

interface Props extends SuccessProps {
  children: React.FunctionComponent<TenantQuery>
}

export const Success = ({ tenant, children }: Props) => {
  return <>{ children({ tenant }) }</>
}

Then to use the Cell in another component:

<TenantCell tenantId={id}>
  {({ tenant }) => (
    <div>{tenant}</div>
  )}
</TenantCell>

One downside is that whatever is defined for Loading and Empty in the Cell will be used across all instances of the cell, no matter how the data is rendered. I’ve found that to be quite limiting in some instances.

1 Like

Thanks! I have thought about this approach, too!

I am quite happy right now with my hooks approach. I was using a simple useQuery wrapper, that would use the QUERY variable of the Cell. But I changed to an approach with Context Provider. This gives me safety in consumption of hooks, since they need to be within the provider. - I think this approch did not come to my mind, since react context is not very performant. But I implemented it now with zustand. Zustand context is already much more performant than usage of useQuery. I will share the code - could be easily adapted to a custom generator for cells, too.

Context

createCellContext.tsx

import { createContext, PropsWithChildren, useContext, useRef, useLayoutEffect } from 'react'
import { createStore, useStore } from 'zustand'

/**
 * zustand context
 * https://docs.pmnd.rs/zustand/guides/initialize-state-with-props
 * */
export function createCellContext<T extends any>() {
  interface CellContextProps {
    data: T
  }

  type CellContextStore = ReturnType<typeof createCellContextStore>

  const createCellContextStore = (initProps: CellContextProps) => {

    return createStore<CellContextProps>()((set) => ({
      ...initProps
    }))
  }

  const CellContext = createContext<CellContextStore | null>(null)
  type CellContextProviderProps = PropsWithChildren<CellContextProps>

  function CellContextProvider({ children, ...props }: CellContextProviderProps) {
    const storeRef = useRef<CellContextStore>(createCellContextStore(props))
    if (!storeRef.current) {
      storeRef.current = createCellContextStore(props)
    }

    useLayoutEffect(()=>{
      // update store, if props change
      storeRef.current.setState({data: props.data})
    },[props])

    return <CellContext.Provider value={storeRef.current}>
      {children}
    </CellContext.Provider>
  }

  function useCellContext() {
    const store = useContext(CellContext)

    if (!store) throw new Error('Missing CellContextProvider in the tree')
    const storeData = useStore(store, (state) => state.data)
    return storeData
  }
  return {
    CellContextProvider,
    useCellContext

  }
}

Cell

And here consumption in Cell

TenantCell.tsx

import type { FindTenantById } from 'types/graphql'
import type { CellFailureProps, CellSuccessProps } from '@redwoodjs/web'
import { PropsWithChildren } from 'react'
import { createCellContext } from 'src/utils/createCellContext'
const { CellContextProvider, useCellContext } = createCellContext<FindTenantById['tenant']>()

export const QUERY = gql`
  query FindTenantById($id: String!) {
    tenant: tenant(id: $id) {
     id
  }
`

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

export const Empty = () => <div>Tenant not found</div>

export const Failure = ({ error }: CellFailureProps) => (
  <div className="rw-cell-error">{error?.message}</div>
)

export const Success = ({ tenant, children }: 
PropsWithChildren<CellSuccessProps<FindTenantById>>) => {
  return <CellContextProvider data={tenant}>
    {children}
  </CellContextProvider>
}

export const useTenantCell = useCellContext
2 Likes