When to update model before view

I have a component I’m using to display a list of related records for a model. I’m using didReceiveAttrs to create a new related record if one doesn’t already exist. This allows the user to always have a new record to enter data into, as well as the list of previous records. This works fine in 3.14, but after updating to 3.28, adding the record causes an error about updating [] while rendering. Seems like the intention for didReceiveAttrs would be to set up the component before rendering.

For now I’ve figured out how to use nvm so I can continue to build 3.14. I guess I’ll have to get this figured out eventually, I’d appreciate any ideas.

Ideally you’d be rewriting this component as a glimmer component anyway, which means didReceiveAttrs wouldn’t be available anymore.

I don’t know any details about the code but I imagine you could use a “new style” computed property, something like:

  get recordToEdit() {
    let recordOrNah = this.args.model.<relationship>.findBy('isNew'); // this is just an arbitrary lookup to evaluate the "if one doesn’t already exist" case
    if (recordOrNah) return recordOrNah;
    else return this.store.createRecord(...);
  }

And obviously expand that based on your needs. This means a CP with side-effects so you want to be careful how you’re managing that extra state. You may want to add the cached decorator to ensure this doesn’t get recalculated too much.

If you really want an analog for didReceiveAttrs you could use the {{did-update}} render helper or render modifier but that usually isn’t necessary.

I changed it to a Glimmer component and it works. However, I have the same notice as a deprecation instead of an error.

DEPRECATION: You attempted to update `[]` on `<(unknown):ember1302>`, but it had already been used previously in the same computation.  Attempting to update a value after using it in a computation can cause logical errors, infinite revalidation bugs, and performance issues, and is not supported.

DEPRECATION: You attempted to update `notes` on `<run-schedules@model:house::ember1303:1288>`, but it had already been used previously in the same computation.  Attempting to update a value after using it in a computation can cause logical errors, infinite revalidation bugs, and performance issues, and is not supported.

and after creating the note:

DEPRECATION: You attempted to update `content` on `PromiseManyArray`, but it had already been used previously in the same computation.  Attempting to update a value after using it in a computation can cause logical errors, infinite revalidation bugs, and performance issues, and is not supported.
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class NoteListComponent extends Component {
  @service store;

  constructor(owner, args) {
    super(owner, args);
    
    let house = this.args.house;
    console.log(`note-list.js house ${house.get('orgId')} has ${house.get('notes.length')} shoot notes`);
    let newNote = house.get('notes').findBy('isNew', true);
    console.log(`note-list.js found newNote ${newNote}`);
    if (!newNote) {
      console.log(`note-list.js creating new note`);
      let ignoredBlankNote = this.store.createRecord('note', { house: house });
      console.log(`note-list.js created new note`);
    }
    
    this.notes = house.get('notes');
  }
}

If I use a computed property, I get this error when inserting the new record:

Uncaught Error: Assertion Failed: You attempted to update `[]` on `<(unknown):ember1302>`, but it had already been used previously in the same computation.  Attempting to update a value after using it in a computation can cause logical errors, infinite revalidation bugs, and performance issues, and is not supported.

For context, the NoteList component is called when a modal is displayed which displays the list of notes. The note-list.hbs file just delegates the drawing of each note to another component.

<div class='list-group'>
	{{#each this.notes as |note|}}
		{{note note=note}}
	{{/each}}
</div>

I guess this is probably related to interaction with the data store but it seems like there should be a good way for a component to compute the data to be displayed. I can see about computing this data in the parent component but it would make a lot more sense to have this functionality encapsulated here.

Doing this kind of setting-of-tracked-data based on other tracked data is always going to throw that error, for precisely the reason the error message tells you: you can very easily trigger infinite re-rendering that way. The rendering system enforces that you cannot set reactive (tracked) data you have already read from while rendering as a result.

However, in these cases, if you have to (always a question worth asking!) you can sometimes use a combination of untracked local state with smart uses of getters to accomplish the thing you need to: you cache and supply a newly created object in untracked state if it does’t exist. That’s a “fine” approach for e.g. form draft state and similar problems. Here, that approach won’t work without some further tweaking, because when you call this.store.createRecord('note', { house: house });, you are in fact trying to update tracked state you’ve already read in the same computation: the list of Notes in the store!

The problem here, which the rendering layer is telling you, is not that you are computing the data to be displayed (that would be fine!), it’s that your computation of data to be displayed is also creating data!

What I would suggest here is building the list of notes in a single place—the route, if it’s part of the model, or a parent component, if it’s in response to an action, or similar.

While I understand the desire to sort of “encapsulate” this concern in the component here, the requirement that you actually do one-way data flow will often make that more complicated—but in my experience, putting all the data updates together in a single place is much better long-term: it makes it very much easier to understand the data flow throughout the app.[1]

The other thing is, you’re not actually encapsulating this particular data concern by putting it in this component as long as you’re creating a model in the data store. The data store is inherently and inescapably a global concern!

One alternative approach this suggests is to have a piece of data here which is a “draft” of the state you want to create, and which you only turn into a value in the store once the user persists it. That does allow you to keep the concern local and encapsulated, precisely because it doesn’t couple this to the data store.

In that approach, your component might look something like this:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

class NoteDraft {
  house; // can also be tracked if need be
  @tracked content; // This should be like the field in your note model!
}

export default class NoteListComponent extends Component {
  @service store;

  #draft;
  #prevHouse;

  get draft() {
    if (this.args.house !== this.#prevHouse) {
      this.#draft = new NoteDraft({ house: this.args.house });
    }

    return this.#draft;
  }

  get notes() {
    const houseNotes = this.args.house.get('notes');
    return houseNotes.some((note) => note.isNew)
        ? houseNotes
        : houseNotes.concat(this.draft);
  }

  @action saveDraft() {
    const note = this.store.createRecord('note', this.#draft);
    note.save();
  }
}

The key bit to understand here is the draft getter: we want a new, cached draft any time the house diverges from the previous house value. This is not tracked, and acts like a cache: any time we get a new house, we get a new draft, but it’s otherwise stable. Note that that we never read from tracked state that we’re also assigning to this way! That’s important: it keeps us from having infinite re-rendering cycles. It also will return the same #draft no matter how times we ask for it, so we get legitimate caching: we’re never accidentally creating a new draft just by asking for the value of the getter this way. It’s also lazy: it will be created on demand when we ask for it in the notes getter below it, and not otherwise.

The net here is that we have true one-way data flow, no infinite re-rendering concerns. It’s slightly less clean than just having all of this happen at the level where your app notionally “owns” the list of notes, so that would still be my first recommendation—but it works well and maintains the “encapsulation”.


Notes

  1. As a bonus: I often think about how I would approach these problems if I were working in a system like Elm or Redux, where the state is all in a single structure—or even in something like Shpadoinkle, where local state transformations have to be pure functions, even though they’re local. For a case like this, you would update the shared state store all together in the same action because you would have to.

Whether I have to is I guess up to me. As it stands, the app behavior is ideal.

  • A new note is only created if the notes list is viewed for a house.
  • If the user views notes for a different house, they get a new note for that house.
  • If they go back to the previous house, the new note which they may have started entering data still has their data.
  • Saving a note is simple, persists it, and pushes it to the back end; no faffing about with temp variables to copy, reset, etc.

As it stands the code is clear as well (at least for me, mostly Rails and mac/ios app development). When the notes are displayed, make sure there’s a new note. (A common Rails idiom is to create a new (not persisted) instance of an ActiveRecord model to render a form for a new record.) If the user never looks at the notes list, nothing related to notes needs to happen.

Managing a NoteDraft class probably won’t be that bad in this case. Since notes can sometimes be edited, I guess the save action can check to see if a the note being saved is a Note or NoteDraft. There would be no need to check for for note.isNew in get notes() because there could never be a new Note, only persisted Notes and a NoteDraft.

Thanks for pointing me toward a solution. Fortunately this is the only spot in this app with user-editable data!