DEV Community

loading...

Container Trap

¯\_(Kλmal)_/¯
Hi, my pronouns are he/him.
・4 min read

There is a popular idea in React which is to separate components as dumb and smart components. Is it still relevant?

Separation of concerns and reusability are primary problems smart and dumb components solve. We get to separate API, Animations, Routing, and other logic away from some components which just take in data as props and render.

To summarise, dumb components are concerned with how things look. They take in data through props, have no connection to any global store. The way they communicate is with callback props.

Container components are smart. They connect with global stores like redux. They make API calls, do some parsing on response, subscribe to event listeners for data, and just pass this data along to dumb components.

The primary con of this is it ends up leading to early abstraction. For example, pages would have multiple containers and no logic in that page, or worse, we may have a page called Home and a container called HomeContainer.

I see this as a trap. Smart and dumb components is an idea Dan Abramov has amplified with this blog post. Although he updated it just after hooks were announced. He saw how smart and dumb components solve these problems, also can be solved without splitting them as such.

To be clear, splitting is necessary but we definitely can do better than splitting them as presentation and container components.

Let's look at these new patterns that help us solve this problem without containers. One of the primary or redundant pieces of logic every app would/will have is to handle API response states.

function App() {
  const [state, setState] = useState({
        data: null,
        error: null,
        isLoading: false
    })

  useEffect(() => {
    const fetchData = async () => {
            try {
        const result = await fetch(`http://hn.algolia.com/api/v1/hits`)
                setState({
                    data: result.data,
                    error: null,
                    isLoading: false
                })
            }  catch (err) {
                setState({
                    data: null,
                    error: err,
                    isLoading: false
                })
            }
    };

    fetchData()
  }, [])

    if (isLoading)
        return <h1>loading...</h1>
    else if (error)
        return <h1>{error.message}</h1>

  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is somehow better as we use functional components, but this wouldn't solve the separation of concern or reusability yet. We are still managing/updating the state at the same place we are rendering the list. What else can we do here?

We can make a hook that returns these loading, error, and data states by taking in a promise:

const fetchData = () => {
        return fetch(`http://hn.algolia.com/api/v1/hits`)
};

function App() {
  const {isLodaing, error, data} = useAsync(fetchData)

    if (isLoading)
        return <h1>loading...</h1>
    else if (error)
        return <h1>{error.message}</h1>

  return (
    <ul>
      {data.hits.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );

Enter fullscreen mode Exit fullscreen mode

Now we have a component that does not care or worry about how the data is fetched and parsed. This has solved our initial problem with the separation of concerns without needing to use containers.

Lets look into what useAsync does:

const useAsync = (fetchData: Promise) => {
    const [state, setState] = useState({
        data: null,
        error: null,
        isLoading: true
    })

    useEffect(() => {
    const runPromise = async () => {
            try {
        const result = await fetchData()
                setState({
                    data: result.data,
                    error: null,
                    isLoading: false
                })
            }  catch (err) {
                setState({
                    data: null,
                    error: err,
                    isLoading: false
                })
            }
    };

    runPromise()
  }, [])

    return {
        data: state.data,
        error: state.error,
        isLoading: state.isLoading
    }
}
Enter fullscreen mode Exit fullscreen mode

Here we are taking in a promise, abstracting what we have earlier done in the App component. Mainly what we are getting is a component logic abstraction. This is definitely not a perfect hook for promises. This one here only demonstrates how we can build primitives that can abstract logic away from components.

We can create many hooks which act as primitives that solve many other problems as well:

rehooks/awesome-react-hooks

Although, there will be cases where we have to split components. For example, assume we have few more API calls and different lists this component has to render or some things that need to be shared with other pages. Then we definitely can't put them together!

This drops to leveraging composition to solve these problems. Another problem with smart and dumb components is that it may feel like we are separating the complex bits away, but we are essentially moving the goal post.

With that approach, we have never solved the fundamental problems with complex components or screens. Instead, we have moved them into folders/files and reaped them on reusability benefit. For most, this has worked because we mostly don't deal with very complex screens.

Here is one way to solve reusability with hooks itself. The problem we are trying to solve here is reusability and giving the parent component more control over the list:


const useList = (defaultState) => {
  const [state, updateState] = useState(defaultState);
  const List = () => (
     <ul>
      {state.map(item => (
        <li key={item.objectID}>
          <a href={item.url}>{item.title}</a>
        </li>
      ))}
    </ul>
  );

  return [state, List, updateState];
};

const fetchData = () => {
        return fetch(`http://hn.algolia.com/api/v1/hits`)
};

function PageWithTwoLists() {
  const {isLodaing, error, data} = useAsync(fetchData)
    const [employess, EmployeeList, setEmployees] = useList([])
    const [hits, HitsList, setHits] = useList([])

    useEffect(() => {
        if (data) {
            setHits(data.hits)
            setEmployees(data.employees)
        }
    }, [data, setHits, setEmployees])

    if (isLoading)
        return <h1>loading...</h1>
    else if (error)
        return <h1>{error.message}</h1>

  return (
    <>
      <EmployeeList />
            <HitsList />
    </>
  );

Enter fullscreen mode Exit fullscreen mode

In this, the parent component can see which data the list is rendering, and also it has the control to update the list.

This is very a niche pattern, to return components from hooks. It may not click immediately, but it can be convenient when we want to build components that need to link, as one action in one part should trigger a different step in another element.

I only wanted to highlight hooks and how we can leverage them with this post but there are more ways to solve this problem with composition.

To credit there are other patterns we have already used much before hooks existed such as Render props and compound components. They are still very relevant and useful patterns to solve these problems.

In no way, I want to say these approaches solve this problem forever. It is just another approach we can do now.

Thank you for reading!

Discussion (0)