Search code examples
javascriptreactjsreact-hookscreate-react-appreact-state-management

How to maintain state of child components when they are filtered through the parent component?


I am building a small app using create react app to improve my react knowledge but now stuck with state management.

The apps maps through JSON data on the parent component and prints 6 "image cards" as child components with an array of "tags" to describe it and other data(url, titles etc..) passed as props.

Each card has an input which you can add more tags to the existing list.

On the parent component there is an input which can be used to filter the cards through the tags. (only filters through default tags not new ones added to card).

What I am trying to achieve is maintaining the state of each card when it gets filtered. Currently what happens is if I add new tags to the cards and filter using multiple tags, only the initial filtered cards contain the new tags, the rest get re-rendered with their default tags. Can someone tell me where I am going wrong, thanks.

My project can also be cloned if it makes things easier https://github.com/sai-re/assets_tag

data.json example

{
    "assets": [
        {
            "url": "https://images.unsplash.com/photo-1583450119183-66febdb2f409?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=200&ixlib=rb-1.2.1&q=80&w=200",
            "title": "Car",
            "tags": [
                { "id": "USA", "text": "USA" },
                { "id": "Car", "text": "Car" }
            ],
            "suggestions": [
                { "id": "Colour", "text": "Colour" },
                { "id": "Motor", "text": "Motor" },
                { "id": "Engineering", "text": "Engineering" }
            ]
        },
        {
            "url": "https://images.unsplash.com/photo-1582996269871-dad1e4adbbc7?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=200&ixlib=rb-1.2.1&q=80&w=200",
            "title": "Plate",
            "tags": [
                { "id": "Art", "text": "Art" },
                { "id": "Wood", "text": "Wood" },
                { "id": "Spoon", "text": "Spoon" }
            ],
            "suggestions": [
                { "id": "Cutlery", "text": "Cutlery" },
                { "id": "Serenity", "text": "Serenity" }
            ]
        }
    ]
}

Parent component

import React, {useState} from 'react';
import Item from './Item'
import data from '../../data.json';

import './Assets.scss'

function Assets() {
    const [state, updateMethod] = useState({tag: "", tags: []});

    const printList = () => {
        //if tag in filter has been added        
        if (state.tags.length > 0) {
            return data.assets.map(elem => {
                //extract ids from obj into array
                const dataArr = elem.tags.map(item => item.id);
                const stateArr = state.tags.map(item => item.id);

                //check if tag is found in asset
                const doesTagExist = stateArr.some(item => dataArr.includes(item));
                //if found, return asset 
                if (doesTagExist) return <Item key={elem.title} data={elem} />;
            })
        } else {
            return data.assets.map(elem => (<Item key={elem.title} data={elem} /> ));
        }
    };

    const handleClick = () => {
        const newTag = {id: state.tag, text: state.tag};
        const copy = [...state.tags, newTag];

        if (state.tag !== "") updateMethod({tag: "", tags: copy});
    }

    const handleChange = e => updateMethod({tag: e.target.value, tags: state.tags});

    const handleDelete = i => {
        const copy = [...state.tags];
        let removed = copy.filter((elem, indx) => indx !== i);

        updateMethod({tag: state.tag, tags: removed});
    }

    return (
        <div className="assets">
            <div className="asset__filter">
                <h3>Add tags to filter</h3>
                <ul className="asset__tag-list">
                    {state.tags.map((elem, i) => (
                        <li className="asset__tag" key={`${elem.id}_${i}`} >
                            {elem.text}

                            <button className="asset__tag-del" onClick={() => handleDelete(i)}>x</button>
                        </li>
                    ))}
                </ul>

                <input 
                    type="text" 
                    value={state.tag}
                    onChange={handleChange} 
                    placeholder="Enter new tag" 
                    className="asset__tag-input"
                />

                <button className="asset__btn" onClick={handleClick}>Add</button>
            </div>

            <div className="item__list-holder">
                {printList()}
            </div>
        </div>
    );  
}

export default Assets;

Child component

import React, {useState, useEffect} from 'react';

function Item(props) {
    const [state, updateMethod] = useState({tag: "", tags: []});

    const handleClick = () => {
        //create new tag from state
        const newTag = {id: state.tag, text: state.tag};
        //create copy of state and add new tag
        const copy = [...state.tags, newTag];
        //if state is not empty update state with new tags
        if (state.tag !== "") updateMethod({tag: "", tags: copy});
    }

    const handleChange = e => updateMethod({tag: e.target.value, tags: state.tags});

    const handleDelete = i => {
        //copy state
        const copy = [...state.tags];
        //filter out tag to be deleted
        let removed = copy.filter((elem, indx) => indx !== i);
        //add updated tags to state
        updateMethod({tag: state.tag, tags: removed});
    }

    useEffect(() => {
        console.log("item rendered");
        //when first rendered, add default tags from json to state
        updateMethod({tag: "", tags: props.data.tags});
    }, [props.data.tags]);

    const assets = props.data;

    return (
        <div className="item">
            <img src={assets.url} alt="assets.title"/>
            <h1 className="item__title">{assets.title}</h1>

            <div className="item__tag-holder">
                <ul className="item__tag-list">
                    {state.tags.map((elem, i) => (
                        <li className="item__tag" key={`${elem.id}_${i}`} >
                            {elem.text}
                            <button className="item__tag-del" onClick={() => handleDelete(i)}>x</button>
                        </li>
                    ))}
                </ul>

                <input 
                    type="text" 
                    value={state.tag} 
                    onChange={handleChange} 
                    placeholder="Enter new tag" 
                    className="item__tag-input"
                />

                <button className="item__btn" onClick={handleClick}>Add</button>
            </div>
        </div>
    );
}

export default Item;

Solution

  • Render all items, even if they are filtered out, and just hide the items filtered out using CSS (display: none):

    const printList = () => {
        //if tag in filter has been added        
        if (state.tags.length > 0) {
            // create a set of tags in state once
            const tagsSet = new Set(state.tags.map(item => item.id));
            return data.assets.map(elem => {
                //hide if no tag is found
                const hideElem = !elem.tags.some(item => tagsSet.has(item.id));
    
                //if found, return asset 
                return <Item key={elem.title} data={elem} hide={hideElem} />;
            })
        } else {
            return data.assets.map(elem => (<Item key={elem.title} data={elem} /> ));
        }
    };
    

    And in the Item itself, use the hide prop to hide the item with CSS using the style attribute or a css class:

    return (
        <div className="item" style={{ display: props.hide ? 'none' : 'block' }}>
    

    You can also simplify printList() a bit more, by always creating the Set, even if state.tags is empty, and if it's empty hideElem would be false:

    const printList = () => {
      const tagsSet = new Set(state.tags.map(item => item.id));
    
      return data.assets.map(elem => {
        //hide if state.tags is empty or no selected tags
        const hideElem = tagsSet.size > 0 && !elem.tags.some(item => tagsSet.has(item.id));
    
        //if found, return asset 
        return (
          <Item key={elem.title} data={elem} hide={hideElem} />
        );
      })
    };