It seems it's time to learn a new React hook... First experience implementing useReducer in a project.

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.

The idea

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.

The task

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.

The problem

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.

Solving the problem

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.

snoppets-page.jpg

Once again, a short description of the component:

  • the component should have a body with items and a filter
  • each item must have tags that will be used as filtering criteria
  • on load, the component should fetch all data and prepare it to show as items
  • on load, the components should collect all tags from items to render filter buttons for each tag
  • on filter button click, the component should be rerendered and show only items with a selected tag
  • 2 tags could be chosen at the same time - more items will be shown
  • the unselected filter should render all items again
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:

  • snippets - all entries
  • filter - will represent filter content and state (isActive, isSelected)
  • defaultFilter - to reset filter to default.
  • Intro - text from the API for a heading section of a page

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.

To conclude

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).