0

I have an a state object in React that looks something like this (book/chapter/section/item):

  const book = {
     id: "123",
     name: "book1",
     chapters: [
      {
        id: "123",
        name: "chapter1", 
        sections: [
          {
           id: "4r4",
           name: "section1",
           items: [
            {
              id: "443",
              name: "some item"
            }
           ]
          }
        ]
      }, 
      {
        id: "222",
        name: "chapter2",
        sections: []
      }
     ]
  }

I have code that adds or inserts a new chapter object that is working. I am using:

// for creating a new chapter:
setSelectedBook(old => {
   return {
     ...old,
     chapters: [
       ...old.chapters, 
       newChapter // insert new object
      ]
   }
})

And for the chapter update, this is working:

setSelectedBook(old => {
    return {
       ...old,
       chapters: [
         ...old.chapters.map(ch => {
           return ch.id === selectedChapterId
             ? {...ch, name: selectedChapter.name}
             : ch
           })
       ]
    }
})

But for my update/create for the sections, I'm having trouble using the same approach. I'm getting syntax errors trying to access the sections from book.chapters. For example, with the add I need:

// for creating a new section:
setSelectedBook(old => {
   return {
     ...old,
     chapters: [
       ...old.chapters,
       ...old.chapters.sections? 
       newSection // how to copy chapters and the sections and insert a new one?
      ]
   }
})

I know with React you're supposed to return all the previous state except for what you're changing. Would a reducer make a difference or not really?

I should note, I have 4 simple lists in my ui. A list of books/chapters/sections/items, and on any given operation I'm only adding/updating a particular level/object at a time and sending that object to the backend api on each save. So it's books for list 1 and selectedBook.chapters for list 2, and selectedChapter.sections for list 3 and selectedSection.items for list 4.

But I need to display the new state when done saving. I thought I could do that with one bookState object and a selectedThing state for whatever you're working on.

Hopefully that makes sense. I haven't had to do this before. Thanks for any guidance.

MattoMK
  • 609
  • 1
  • 8
  • 25
  • 1
    if you have to push a new chapter into your book it's possible but if you want a new section then you have to pass a specific index of the array and after it might be possible. – Meet Majevadiya Jun 29 '22 at 17:46

3 Answers3

1

I think the map should work for this use case, like in your example.

setSelectedBook(old => {
    return {
       ...old,
       chapters: [
         ...old.chapters.map(ch => {
           return { ...ch, sections: [...ch.sections, newSection] }
           })
       ]
    }
})

In your last code block you are trying to put chapters, sections and the new section into the same array at the same level, not inside each other.

tperamaki
  • 1,028
  • 1
  • 6
  • 12
  • Of course you're right. I guess I was expecting that chapters: [...old.chapters,] would copy each key of each chapter obj in the chapter array, and then on the next line I could say 'sections.' and get some help from the compiler. That it would know I'm trying to now access that sections collection. But what you put here looks like it should work, didn't think about mapping to the next level. And for the section update, I'd need another map to say return {...ch, sections: [...ch.sections.map..] to find the correct one? And then for items another map. – MattoMK Jun 29 '22 at 18:19
  • Yeah that should work. But as stated in other answers, if it starts getting too complicated, you should think about storing the state in some other way to not end up with code that is hard to understand and/or maintain. – tperamaki Jun 29 '22 at 18:28
  • Question about your example that adds a new section- wouldn't that add a newSection to each chapter by using map? I haven't tested that yet, i was just taking a second look trying to imagine what it would do. Wouldn't it take each chapter and copy over its sections and add that new section to each parent? – MattoMK Jun 29 '22 at 20:52
  • 1
    Yes this piece of code would add the new section to every chapter. Simple if-clause should do the trick for example, so you can either return that modified chapter or just the ch if this is not the one you want to modify. – tperamaki Jun 29 '22 at 21:30
  • thank you. last question: I have have another level, items, off of sections. In this example above, will I have to map over those explicitly too? Iow, does the spread take everything except what you override explicitly? Or do you have to do a map for each level even if you know you're not modifying it? – MattoMK Jun 29 '22 at 21:51
1

Updating deep nested state objects in React is always difficult. Without knowing all the details of your implementation, it's hard to say how to optimize, but you should think hard about different ways you can store that state in a flatter way. Sometimes it is not possible, and in those cases, there are libraries like Immer that can help that you can look in to.

Using the state object you provided in the question, perhaps you can make all of those arrays into objects with id for keys:

const book = {
     id: "123",
     name: "book1",
     chapters: {
      "123": {
        id: "123",
        name: "chapter1", 
        sections: {
          "4r4": {
           id: "4r4",
           name: "section1",
           items: {
            "443": {
              id: "443",
              name: "some item"
            }
           }
          }
        }
      }, 
      "222": {
        id: "222",
        name: "chapter2",
        sections: {},
      }
     ]
  }

With this, you no longer need to use map or find when setting state.

// for creating a new chapter:
setSelectedBook(old => {
   return {
     ...old,
     chapters: {
       ...old.chapters, 
       [newChapter.id]: newChapter
     }
   }
})

// for updating a chapter:
setSelectedBook(old => {
   return {
     ...old,
     chapters: {
       ...old.chapters, 
       [selectedChapter.id]: selectedChapter,
     }
   }
})

// for updating a section:
setSelectedBook(old => {
   return {
     ...old,
     chapters: {
       ...old.chapters, 
       [selectedChapter.id]: {
         ...selectedChapter,
         sections: {
           [selectedSectionId]: selectedSection
         }
       },
     }
   }
})

Please let me know if I misunderstood your problem.

Justin Chang
  • 194
  • 12
  • That's a really cool idea. In my current case I can't do that though. I'm going to try immer, especially when things get really hairy. it looks like it would help me a lot. – MattoMK Jun 30 '22 at 01:29
1

for adding new Section

setSelectedBook( book =>{
   let selectedChapter = book.chapters.find(ch => ch.id === selectedChapterId )

   selectedChapter.sections=[...selectedChapter.sections, newSection ]

    return {...book}

})

For updating a section's name

setSelectedBook(book=>{
   let selectedChapter = book.chapters.find(ch => ch.id === selectedChapterId )
   let selectedSection = selectedChapter.sections.find(sec => sec.id === selectedSectionId )

   selectedSection.name = newName

    return {...book}
})

For updating item's name

setSelectedBook(book =>{
   let selectedChapter = book.chapters.find(ch => ch.id === selectedChapterId )
   let selectedSection = selectedChapter.sections.find(sec => sec.id === selectedSectionId )
   let selectedItem = selectedSection.items.find(itm => itm.id === selectedItemId) 

   selectedItem.name = newItemName

    return {...book}
})

I hope you can see the pattern.

RHOOPH
  • 94
  • 1
  • 5
  • thank you.. Does returning old like this return everything as-is except for the assignment part? I've never seen it done like this, way cleaner looking. – MattoMK Jun 29 '22 at 23:06
  • 1
    No, it returns everything with modified changes. `.find` method returns the element that it finds. In this case it's an object. Objects are passed by reference. So when it's modified, the original object also gets modified. I will edit `old` to `book` so that it better reflects what's happening. – RHOOPH Jun 30 '22 at 05:48
  • Fantastic. I keep reading about how working with nested objects/arrays is error prone because it only does a shallow copy and you should use tools like immer and different ways to address it. Is your example a way to avoid that issue, or is the issue not applicable in my case? I'm currently using the more complicated syntax with all the checks in one statement. As soon as I have my page working and want to clean up, I will be trying your example. It's looking gnarly especially at the 4th level (items). Can't say how happy I'll be to use this method. – MattoMK Jun 30 '22 at 17:43
  • It does not avoid issue of shallow copy. In fact we are directly modifying the state. And setting the modified state as the new state. There can be edge cases where this might cause problem ,like when `setSelectedBook` is called multiple time in single render. Deep clone `book` If you want to avoid that. Note: changed `return book` to `return {...book}` Since `React` wouldn't trigger re-render as reference to old state is same as the new state. – RHOOPH Jul 01 '22 at 06:17
  • is there any difference in terms of potential errors due to directly modifying state, between the example above and something like: setSelectedBook(old => { return { ...old, chapters: [ ...old.chapters.map(ch => { return { ...ch, sections: [...ch.sections, newSection] } }) ] } }) Just trying to find out if the difference is simply readability and all else is equal with the two approaches. Yours seems to work great, but I don't know if one is buggier than the other. thanks – MattoMK Jul 01 '22 at 19:54
  • As we are modifying state inside the state setter itself , it is very safe. – RHOOPH Jul 02 '22 at 09:31
  • As long as you stick to that rule , you should be fine. If you are delegating the task of calculating state to some other function , then it is recommended to either spread/clone – RHOOPH Jul 02 '22 at 10:07