I've used useState and useEffect hooks extensively and in different ways, but sometimes it was not that easy to control all the data in a component with just that. Then, I remembered about another hook that I'd never tried before so I checked it and found that it was basically a solution to a feature that I was planning to implement on my website.
So here we have a useReducer in use and how I created the "Snippets" page with a filter.
From the start, I wanted to have a page on my website that will contain all kinds of different bits of code that I come across, too lazy to memorize, and will probably use in the future. But building that page will require a bit of thinking since I wanted it to be useful, with something that will allow me to quickly search for a snippet in a potentially huge list. So it was a bit tricky to create but also pushed me to learn some new stuff.
Create a page with a big list of entrees. Each one should be relatively small in a list view. The page should have some sort of filter to faster browse through all the items.
My attempts to build it with just useState hook was rather bad because pretty quickly I got a bunch of states for here and there and controlling all of it was really hard. I mean, it could be done undoubtedly, however, I've always thought that things done in a hard way might not be reliable, or maintainable (in the case of coding)...
So the answer to that situation as I saw it was to drop the idea of doing it with useState and try something new, something meant to control complex states, something like Redux, or, as I found out - useReducer.
Now when it's settled, it's time to learn. Some time on youtube and documentation gave me an idea of how should I use it. Now all I need is to double-check what I want to build and how should it work to fully understand the component's state data and what the reducer should do with it.
Once again, a short description of the component:
export async function getStaticProps() {
const { data: {snippets} } = await client.query({
query: gql`{
snippets {
title
slug
tags
excerpt
createdAt
}
}`
});
// Collecting all tags from snippets, each tag only once (we I don't need any copies)
let tagsArray = []
snippets.forEach(snippet => snippet.tags.forEach( tag => !tagsArray.includes(tag) && tagsArray.push(tag)));
// Using previously collected tags array to build an object before passing in to a page
let newArr = [];
tagsArray.forEach(item => newArr.push({tag: item, isActive: true, isSelected: false}));
const initialContent = { snippets: snippets, filter: newArr, defaultFilter: newArr, intro: intros[0] }
return { props: initialContent};
}
This bit of code shows how to get basic data with snippets on SSR time and prepare it for a page before sending it.
So, get the initial data, collect tags from snippets, make an object out of those tags, pass it to a page along with other data:
Now it's time to receive that data and use it to render the component.
function Index( {snippets, filter, defaultFilter, intro} ) {
const [content, dispatch] = useReducer(reducer, {snippets, filter, defaultFilter, allSnippets: [...snippets]});
return <>
...
<div className="tags-filter flex flex-row mt-2 flex-wrap">
{
content.filter && content.filter.map( (filter, index) =>
<button className={`tag px-2 py-1 border m-2 lg:m-0 lg:mr-2 ${filter.isSelected ? 'selected' : ''}`}
disabled={!filter.isActive} key={index} onClick={handleFiltering} value={filter.tag} >
{filter.tag}
</button>
)
}
</div>
...
<div className="snippets py-8 flex flex-row flex-wrap items-stretch">
{
content.snippets && content.snippets.map( (snippet, index) =>
<Snippet snippet={snippet} key={index} tags={snippet.tags} />
)
}
</div>
</>
Incoming data passed as initial content of the Reducer hook, and the component renders 2 parts from it - filter and items.
Buttons in the filter get classes conditionally, and other properties. Value prop of the button used in the handleFiltering function to show what button was pressed.
function handleFiltering(e) {
if(e.target.classList.contains("selected")) {
// Disabling filter..
dispatch({type: ACTIONS.REMOVE_FILTER, payload: {tag: e.target.value}});
return
} else {
// Adding new filter
dispatch({type: ACTIONS.ADD_FILTER, payload: {tag: e.target.value}})
return
};
}
This is a mentioned above function, that meant to check if pressed button already selected (through the .selected class) or not and act accordingly.
Now I'm going to throw the whole reducer here....
const ACTIONS = {
ADD_FILTER: 'add',
REMOVE_FILTER: 'remove',
RESET: 'reset'
}
function reducer(content, action) {
const initState = content.allSnippets; //all 8 items in its default state
switch(action.type) {
case ACTIONS.ADD_FILTER: {
const updatedFilter = content.filter.map( item => item.tag === action.payload.tag
? {...item, isSelected: true}
: {...item});
const updatedSnippets = filterSnippets(updatedFilter);
return {...content, filter: updatedFilter, snippets: updatedSnippets}
}
case ACTIONS.REMOVE_FILTER: {
const updatedFilter = content.filter.map( item => item.tag === action.payload.tag ? {...item, isSelected: false} : {...item});
const activeTags = updatedFilter.reduce( (arr, item) => item.isSelected ? [...arr, item.tag] : arr, [])
if(activeTags.length > 0) {
const updatedSnippets = filterSnippets(updatedFilter);
return {...content, filter: updatedFilter, snippets: updatedSnippets}
} else {
return {...content, snippets: initState, filter: content.defaultFilter}
}
}
case ACTIONS.RESET: {
return {...content, snippets: initState, filter: content.defaultFilter}
}
}
function filterSnippets(filters) {
const activeTags = filters.reduce( (arr, item) => item.isSelected ? [...arr, item.tag] : arr, [])
const newSnippets = initState.reduce( (arr, snippet) => {
activeTags.forEach( tag => {
if(snippet.tags.includes(tag) && (!arr.some(item => item.slug === snippet.slug))) {
arr.push(snippet)
}
})
return arr;
}, [])
return newSnippets;
}
}
What can I say about that function... I wrote it quite some time ago and for me, the toughest part was selecting snippets that contain at least 1 tag from the tags array. Some things I did with a forEach loop inside of another forEach loop, others with an array.reduce method. It took me some time honestly but well, here it is working as I wanted.
When I was writing that code I thought that it might be better for a filter to reduce results on the second filter button pressed, instead of adding new items to a filtered bunch. However, a quick pondering on how to do it resulted in putting such function in a waiting list, at least until I have a really big list of snippets.
This is it. The page is available on my GitHub page as a part of the project in a ../pages/snippets/index.js file.
That filter definitely gave me a headache for some time but I think the result is pretty good and apart from that I have got tons of practice on array methods and learned a new React hook (even though the use-case is not that complex).