2

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;
Sai
  • 801
  • 3
  • 10
  • 27
  • Those input values must be in parent state. So it's remembered when they are filtered out of the render. Could that be the issue? – cbdeveloper Apr 08 '20 at 15:25

2 Answers2

1

The problem that you are facing is that the cards that disappear are unmounted, meaning that their state is lost. The best solution is keeping the new custom tags you add to cards in the parent component, so it's persistent, no matter if the card is mounted or not. Here are the modified files:

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 [childrenTags, setChildrenTags] = useState(data.assets.map(elem => elem.tags));

    const addChildrenTag = (index) => (tag) => {
        let newTags = Array.from(childrenTags)
        newTags[index] = [...newTags[index], tag]

        setChildrenTags(newTags)
    }

    const removeChildrenTag = (index) => (i) => {
        let newTags = Array.from(childrenTags)
        newTags[index] = newTags[index].filter((elem, indx) => indx !== i)

        setChildrenTags(newTags)
    }

    const printList = () => {
        //if tag in filter has been added        
        if (state.tags.length > 0) {
            return data.assets.map((elem, index) => {
                //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} 
                            customTags={childrenTags[index]} 
                            addCustomTag={addChildrenTag(index)}
                            removeCustomTag={removeChildrenTag(index)}
                        />
                    )
            })
        } else {
            return data.assets.map((elem, index) => (
                <Item 
                    key={elem.title} 
                    data={elem} 
                    customTags={childrenTags[index]} 
                    addCustomTag={addChildrenTag(index)}
                    removeCustomTag={removeChildrenTag(index)}
                />
            ));
        }
    };

    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: ""});
    cosnst tags = props.customTags
    cosnst addCustomTag = props.addCustomTag
    cosnst removeCustomTag = props.removeCustomTag

    const handleClick = () => {
        if (state.tag !== "") addCustomTag(state.tag);
    }

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

    const handleDelete = i => {
        removeCustomTag(i);
    }

    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">
                    {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;

Hope this helps, I can add some comments if anything is unclear :)

Adam Jeliński
  • 1,708
  • 1
  • 10
  • 21
  • Hi Adam, i've tried integrating it in however when I add a new tag to the card an empty string is added instead of the input. Although your solution does keep the same state after filtering, am I correct in saying that you're essentially keeping the cards state on the parent and using callbacks to update it through props? – Sai Apr 08 '20 at 18:22
  • 1
    This is correct, the parent is keeping all the state and callbacks `addCustomTag` and `removeCustomTag` are used to modify this state – Adam Jeliński Apr 08 '20 at 18:28
  • 1
    I'm really not sure though, why would an empty string be pushed to the tags instead of your input. Maybe try logging the `state.tag` in the `handleClick` and `tag` inside `addChildrenTag` to check if everything is passed around correctly? – Adam Jeliński Apr 08 '20 at 18:33
1

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} />
    );
  })
};
Ori Drori
  • 183,571
  • 29
  • 224
  • 209