Engineering

Hot Take: If you’re using Hooks instead of Containers, you’re doing it wrong

Stephanie YangStephanie Yang
2/20/2024
Hot Take: If you’re using Hooks instead of Containers, you’re doing it wrong
Copy link

Introduction

As a developer, one thing that comes into play with any new project is figuring out which design patterns to follow and what is most appropriate for the given project. At Good Code, we often assist startups in building applications from the ground up. This means we can implement design patterns from the start, making it easy to scale for the future.

One pattern we often recommend is the Container-Presentation design pattern in React applications. Let’s dive into what exactly this pattern is and why we recommend it.

What is a Container vs a Presenter?

Container components are React components responsible for handling data and logic. They are typically used to fetch data from an external source, manage state, and pass data down to presentational or "dumb" components.

Presenter components are React components responsible for handling the display of the data being passed to them. They don’t worry about how the data gets to them, just how the data will display when it gets there.

What are the cons of this approach?

Let’s first talk about a couple reasons why you may not choose this approach:

What are the pros of this approach?

There are certainly other approaches that are popular such as Hooks or HOC (Higher Order Components), but after building several large scale applications, I’ve found that while the Container-Presentation pattern may not be a perfect solution, it does have many benefits:

Compared to other mentioned approaches, Hooks is another popular option. The downside I’ve seen with hooks is that it couples the components to the API and, in turn, creates a bit of a nightmare for testing. The HOC pattern can be difficult to follow and can lead to prop collision and prop drilling issues.

Comparing Approaches

Let’s compare the Hooks approach to the Container-Presentation approach through code.

Recently, I worked on a project where the Hooks pattern was preferred. Aside from the testing aspect being more complex, I also noticed this approach lost a bit of the GraphQL awesomeness as multiple query requests had to be made to combine data.

The queries were structured like this:

query Items {
	items {
		id
		name
		... other fields
  }
}

query ItemTypes {
  item_types {
    id
    name
    description
    ... other fields
  }
}

This generated custom hooks for each query that we could use:

const { data, previousData, loading, error } = useItemsQuery();
const { data, loading, error } = useItemTypesQuery();

At some point, we needed to combine this data and check for results that matched a filter (which, was yet another hook). And yes, this was all within a custom hook. So, for those who aren’t keeping track - we now have 3 hooks being used within a hook that is then being used as part of a component which uses… you guessed it, even more hooks.

The wrapper hook looked something like this (spoiler alert - this wrapper hook is basically a Container. Remember that single API request that I mentioned earlier?):

export const useItems = () => {
  const { keyword, filter } = useFilter();
	
	// Notice how here we have to rename variables due to multiple queries
	const { 
		data,
		previousData,
		loading: itemsLoading,
		error: itemsError
	} = useItemsQuery();

	const { 
		data: itemTypesData,
		loading: itemTypesLoading,
		error: itemTypesError
	} = useItemTypesQuery();

	const currentData = data || previousData;

	// We also have to keep track of loading and error state for each query
	const loading = itemsLoading || itemTypesLoading;
  const error = itemsError || itemTypesError;

	return useMemo(() => {
		// Do some stuff

		return { items, loading, error };
	}, [currentData, itemTypesData, loading, error]);
}

And the component looked something like this:

export const Items = () => {
	const { keyword, setKeyword, filter, setFilter } = useFilter();
  const { items, loading, error } = useItems();
  const categories = useItemCategories();

	const handleFilterChange = (value) => {
		const newFilter = {...filter};
		// Determine new filter

		setFilter(newFilter);
	}

	const renderItemsBlock = (item) => {
		return <div />;
	}

	if (loading && !items) {
		return <Loader />
	}

  return (
		<div>
			<Input value={keyword} onChange={setKeyword} />
			<Select value={filter.category} onChange={handleFilterChange}>
				{categories.map(category => 
					<SelectOption>{category}</SelectOption>
				}
			</Select>
			<div>
				{loading && <Loader />}
				{items.map(item => renderItemsBlock(item)}
			</div>
		</div>
	)
}

With this simple example, it might still be fairly easy to follow the code. Handling a filter and search term change here seems straight forward enough. But it’s concerned with both handling the logic and displaying the data. On top of that, the hooks within hooks adds extra layers of complexity, ultimately making the code harder to follow. For example, how does items in this case get updated? To figure that out, we have to go into the hooks file, taking us out of the context of this component. If the file structure is that the hooks stays within this component too, then it feels like we lose the reusability of it or, for other usages, we run into the same problem.

Now, let’s see how things play out if I convert the above to a Container-Presentation approach:

const GET_ITEMS = gql`
	query getItems {
	  items {
			id
			name
			... other fields
	  }
		itemTypes {
	    id
	    name
	    description
	    ... other fields
		}
		categories {
			name
			... other fields
		}
	}
`;

export const ItemsContainer = () => {
	const { keyword, setKeyword, filter, setFilter } = useFilter();
	const { data, previousData, loading, error } = useQuery<{
		items: Item[];
		itemTypes: ItemTypes[];
		categories: Categories[];
	}>(GET_ITEMS);

	const items = useMemo(() => {
		const filtered =[...data.items];

		// Do some stuff

		return filtered;
	}, [data, previousData, loading, error, keyword, filter]);

	const currentData = data || previousData;

	if (loading && !currentData) {
		return <Loader />;
	}

	return (
		<Items
			loading={loading}
			items={items}
			categories={data?.categories}
			onSearchChange={setKeyword}
			onFilterChange={handleFilterChange}
		/>
	)
}

And the view component would look something like this:

export const Items: FC = ({ 
	loading,
	items,
	categories,
	onSearchChange,
	onFilterChange
}) => {
	const renderItemsBlock = (items) => {
		return <div />;
	}

	return (
		<div>
			<Input value={keyword} onChange={onSearchChange} />
			<Select value={filter.category} onChange={onFilterChange}>
				{categories.map(category =>
					<SelectOption>{category}</SelectOption>
				}
			</Select>
			<div>
				{loading && <Loader />}
				{items.map(item => renderItemsBlock(item)}
			</div>
		</div>
	)
}

Here, we end up with two files - a Container component which deals with all of the data fetching and logic, and a Presenter component which purely handles displaying the data and relies on callbacks to the Container to handle any sort of state changes. The Container stays within the same context of the component and I can easily follow the code. If we adhere to this pattern throughout the app, then I can know that this is true for any component, not just this one. There’s no hunting!

Of course, I’m not saying that hooks are bad and should never be used. Like anything else, it’s just another tool in our toolbox and we use it where appropriate. You’ll notice that we’re still using the useFilters hook - that’s because we want this piece to be reusable throughout the app. We’ve designed it to be generic enough to work on any page that needs to be searchable and/or filterable.

Ultimately, we find the second approach easier to follow and also easier to jump in and start making progress. While we can see that the Container component and GraphQL query here are not reusable, it feels like an acceptable trade off since we write GraphQL queries specific to each page. On the flip side, the hooks may be more reusable, but if another component wants to use it and wants another field included, all of the other queries using this hook would also receive the field. There’s no way to curate things specific to a page in this approach without rewriting the query anyways and abstracting that into another hook. Each of these abstractions just makes the entire project harder to follow.

Conclusion

In the end, the most important thing to remember is that everything changes. This is what works or us now, but we have to be flexible and adapt to new ideas and concepts so we continue to grow.

About Good Code

Ready to step into a world where cybersecurity meets a personalized, unparalleled user experience? Unlock the doors to a future where UI/UX isn't just a feature but a testament to seamless collaboration and innovation. Head over to www.goodcode.us – your next tech adventure begins now!



Recommended Posts
Tips from the Good Code Design Team: Staying Productive and Creative While Working Remotely
Tips from the Good Code Design Team: Staying Productive and Creative While Working Remotely
author
Emily Wong
9/26/2024
Announcing reachat - open-source ui building blocks for LLM/chat UIs
Announcing reachat - open-source ui building blocks for LLM/chat UIs
author
Austin McDaniel
8/2/2024
The Link Between Bad UI and Human Errors, and How to Cut It Down
The Link Between Bad UI and Human Errors, and How to Cut It Down
author
Lisa
7/19/2024
Like what you see? Let's talk about your project!