DEV Community

jkonieczny
jkonieczny

Posted on

Using v-model with custom setters

In the previous article we created a proxy for array, so we could easily use v-model on items of a sorted and filtered array. This time we will write an actual Proxy for an object, so we could write our own setters and still use simple v-model, instead of splitting it into :model-value and @update:modelValue. Creating computeds for each field separately isn't a nice way to achieve it. Then how?

Why?

Case: a picker for an item, but when the item is set, we want also to set it’s category in separate field (there can be picked category without selected item, that's why it's in separate field too).

Relations

If we are setting an item, the category must match the item’s category. When we change category, we must ensure that the currently selected item matches the category or clear it. Let’s define our form:

type EntryModel = {
  id: number | null;
  name: string;
  category: CategoryModel | null;
  item: ItemModel | null;
  // ...
};

type ItemModel = {
  id: number | null;
  name: string;
  category: CategoryModel;
  // ...
};

const form = ref<EntryModel>({
  //...
});
Enter fullscreen mode Exit fullscreen mode

We might do it in several ways:

Just leave validation

We could just leave a validation that checks if item has the category equal to the category in entry and force user to change it.

You should definitely have validation for that, just in case, but it’s nice to help user automate things, that can be done automatically.

Using watch

We could simply just watch the item being changed and react on it:

watch(() => form.value.item, (newItem) => {
  if (newItem == null) return;
  if (form.value.category?.id != newItem.category?.id) 
    form.value.category = newItem.category;
});

watch(() => form.value.category, (newCategory) => {
  if (form.value.item?.category.id == newCategory?.id) return;
  form.value.item = null;
});
Enter fullscreen mode Exit fullscreen mode

Simple and… terrible. We will have to be sure we installed that watch only once. While it’s simple to write, its making a mess in the data flow which might end with endless loop. Notice that if we set here an item, the item watch will react and will probably change category, after which the watcher on it will be also called later.

Of course, if the data is valid, it should end here, but in more complex network of dependencies it might cause the infinite loop. It’s also hard to track what is changed and why. Also we need to remember, that watch will notice the change if form.value.category will change, even if the new instance will have the same id, we would need to listen to the id field then.

And what if we’d like to cancel the change, because some dependencies weren’t met, and we couldn’t check them while displaying the list of items to pick? It also might be important to know what user actually tried to set. We could also ask user that the action might have more consequences than changing that one value.

Also, we are watching instances here, not ids. If we assign the same category, but using different instance, the watcher will be called.

Just… no. This. Is. A. Mess.

Using dedicated setter

as I mentioned before, we could write a dedicated setter, we have to assign model-value prop and event separately:

<CategoryPicker
  :model-value="form.category"
  @update:modelValue="setCategory"
/>
<ItemPicker
  :model-value="form.item"
  @update:modelValue="setItem"
/>
Enter fullscreen mode Exit fullscreen mode

And then have the setter written in our setup:

const setCategory = (newCategory: CategoryModel) => {
  form.value.category = newCategory.newItem;
  if (form.value.item?.category?.id != newCategory?.id) {
    form.value.item = null;
  }
}

const setItem = (newItem: ItemModel) => {
  form.value.item = newItem.newItem;
  if (form.value.category.id != newItem.category.id) {
    // if it's required
    form.value.category = newItem.category;
  }
}
Enter fullscreen mode Exit fullscreen mode

It will be executed in one tick, so no worries about unnecessary rerendering. It’s possible also to chain the setters, but we would have again to be careful to not get into infinite loop (actually, here we would get a stack overflow error):

const setItem = (newItem: ItemModel) => {
  form.value.item = newItem.newItem;
  if (form.value.category.id != newItem.category.id) {
    // if it's required
    setCategory(newItem.category);
  }
}

Enter fullscreen mode Exit fullscreen mode

We could also do it with one assignment (we will focus on one setter):

const setItem = (item: ItemModel) => {
  const category = form.value.category.id == item.category.id 
    ? form.value.category 
    : item.category
  form.value = {
    ...form.value,
    item,
    category,
  };
}
Enter fullscreen mode Exit fullscreen mode

This one might be preferred if form is already a modelValue and we want to emit a single event. That way we could also make it async, and before anything would be set:

const setItem = async (item: ItemModel) => {
  const category = form.value.category.id == item.category.id 
    ? form.value.category 
    : item.category
  const res = await checkIfCanBeAssigned({ entry: form.value, item, category });
  if (!res) return;
  form.value = {
    ...form.value,
    item,
    category,
  };
}
Enter fullscreen mode Exit fullscreen mode

We would need to block the inputs in the meantime, but we won’t focus on that now.

Writing an actual proxy

Proxy is the core of Vue’s reactivity. It’s pretty everywhere, so why we shouldn’t write our own? We can’t write our own class with getters and setters, writing a loop with Object.defineProperty is a mess, while we have a much simpler solution.

Basically, Proxy is an object, that calls getters for every field we try to read and setter, when we try to set any. It allows us to track every read and write on the object, let’s look at simpliest Proxy (not TS compatible here):

const proxy = new Proxy(form.value, {
  get (target, key) {
    return target[key];
  },
  set (target, key, value) {
    target[key] = value;
    return true;
  },
});
Enter fullscreen mode Exit fullscreen mode

It simply… doesn’t do anything, just returns fields and sets… What’s interesting, we just wrote something that reactive does! With that, we can set fields with simple proxy.category = and that will just work fine! Why? We give it form.value as a target. We receive that object in getter and setter. Assuming that it was reactive (and ref is deeply reactive), then target[key] = value will be equal to form.value[key] = value. Of course it will fail if the form.value will change (by reassigning the instance by form.value = {...}). So we have to wrap it with a computed:

const proxy = computed({
  get () {
    return new Proxy(form.value, {
      get (target, key) {
        return target[key];
      },
      set (target, key, value) {
        target[key] = value;
        return true;
      }
    });
  },
  set (v) {
    form.value = v;
  }
});
Enter fullscreen mode Exit fullscreen mode

Now when form.value will be reassigned, the computed will return new Proxy instance targeting the new instance, but again, we need to get to the fields by proxy.value, but it shouldn’t be a problem. It’s still just a Ref<T>.

Okay, but what about our setters? We have just one here, right? Yeah, and we need to expand it. First, we need to form an interface. How setter should actually look like?

The most important is that we need to be able to set the field that we originally tried to set, but we found that we need to also be able to set other fields at the same time as well, or… none at all.

How about to make the setter a function, that returns a Partial<T | null> with the fields we want to set (or none, if null)? As arguments we would receive the object itself and value we want to set

const setters = {
  item: async (item: ItemModel) => {
    const category = form.value.category.id == item.category.id 
      ? form.value.category 
      : item.category
    const res = await checkIfCanBeAssigned({ entry: form.value, item, category });
    if (!res) return null; //we set literally nothing
    return { // set just those two fields
      item,
      category,
    };
  },
  category: async (category: CategoryModel) => {
    const item = form.value.item?.category.id == category.id 
      ? form.value.item // keep current
      : null; // clear
    const res = await checkIfCanBeAssigned({ entry: form.value, item, category });
    if (!res) return null;
    return {
      item,
      category,
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

We can end the setter at any moment returning an object. What type should be setters of?

type ProxySetters<T extends object> = Partial<{
  [K in keyof T]: (
    target: T,
    value: T[K]
  ) => Partial<T> | null | Promise<Partial<T> | null>;
}>;
Enter fullscreen mode Exit fullscreen mode

It means, it’s a partial (we don’t need to cover every field) object of the given type T (which must be an object), where key is the name of a field and value is a function that returns Partial<T>, synchronous or asynchronous. Now we need to wrap it into a composable:

export function toProxy<T extends object>(opt: {
  target: Ref<T>,
  setters: ProxySetters<T>,
}) {
  const proxy = computed({
    get () {
      return new Proxy(opt.target.value, {
        get (target: T, key: string) {
          return target[key as keyof T];
        },
        set (target: T, key: string, value: any) {
          const setterFn = opt.setters[key as keyof T];
          if (setterFn) {
            // custom setter available, call it
            const toSet = setterFn(target, value);
            // here we can debug the changes, maybe write your own devtools here?
            opt.target.value = {
              ...opt.target.value,
              ...toSet,
            };
          } else {
            // no custom setter, just apply value
            opt.target.value[key as keyof T] = value;
          }
          return true;
        }
      });
    },
    set (v) {
      opt.target.value = v;
    }
  });

  return {
    proxy
  };
}
Enter fullscreen mode Exit fullscreen mode

We need to do some casting, because TS is sometimes a bit too overprotective, key is typed as string, because user can try to get any field (by either .field or ["field"]), so we need to prepare for any situation), but… we don’t have to care about it here. Outside the Proxy object will be visible as T anyway. Moreover, value is typed as any and… we can’t protect it. TS won’t allow on it’s level to call setter with incompatible type anyway, so again, we don’t have to care about it here, just trust TS.

The problem is that setter can not be async, but… we can easily just return the true and wait for the result with then, like in not so good old times. Let’s focus on the setter in that case:

if (key in opt.setters) {
  const toSet = opt.setters(target, value);
  if (toSet instanceof Promise) {
    toSet.then((toSet) => { // yes, overshadowing
      opt.target.value = {
        ...opt.target.value,
        ...toSet,
      };
    });
  } else {
    // just set, it's the result object
    opt.target.value = {
      ...opt.target.value,
      ...toSet,
    };
  }
  return true;
}

Enter fullscreen mode Exit fullscreen mode

Or following the DRY rule:

function setFields(toSet: Partial<T> | null) {
  if (toSet == null) return; // nothing to do
  // again... devtools? Logs?
  opt.target.value = {
    ...opt.target.value,
    ...toSet,
  };
  // or: Object.assign(opt.target.value, toSet);
  // if you really need to avoid overriding the object instance
};

if (key in opt.setters) {
  const toSet = opt.setters(target, value);
  if (toSet instanceof Promise) {
    toSet.then(setFields);
  } else {
    setFields(toSet);
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

Now it will work both synchronous and asynchronous! But as I mentioned earlier, the asynchronous setter could last a few seconds during which user could try to do something, it’s wise to lock the form for the time data being set.

Let’s set the rule: we can run only one setter at one time. No more. It would be also nice to know which field we are currently setting. Let’s add a variable:

const currentlySetting = ref<keyof T>();
Enter fullscreen mode Exit fullscreen mode

when it’s value is undefined, then no setter is running. Otherwise, it will contain the name of the setter. At the beginning of the Proxy’s setter, we need to put a guard:

set (target: T, key: string, value: any) {
  if (currentlySetting.value) return false;
  // ...
}
Enter fullscreen mode Exit fullscreen mode

We don’t even need to know what user is trying to set, we just don’t care, we shouldn’t allow any changes before the previous ones aren’t done. For synchronous there is no reason to set the variable, but calling the setter we don’t know yet if it will return Promise or not, therefore we need to modify our function:

if (currentlySettting.value) return false;
const setterFn = opt.setters[key as keyof T];
if (setterFn) {
  currentlySetting.value = key;
  const toSet = setterFn(target, value);
  if (toSet instanceof Promise) {
    toSet
      .then(setFields)
      .finally(() => currentlySetting.value = undefined);
  } else {
    setFields(toSet);
    currentlySetting.value = undefined;
  }
  return true;
}
Enter fullscreen mode Exit fullscreen mode

If the function will not be asynchronous currentlySetting will be set and unset in the same Vue’s tick, so there will be actually no change at all. Even if we would put a watch on it, we won’t register any change. But if the setter will be asynchronous, setting the undefined will happen in different tick and the change will be noticed by computed, even if the setter will end without calling any async task.

And we might also want a helper isProcessing, that will return if any field is being setted asynchronically. As I mentioned, if the currentlySetting will be set to anything and back to undefined in the same tick, it won’t change to true at all.

return {
  proxy,
  currentlySettting, //if we need to know exactly which field
  isProcessing: computed(() => currentlySetting.value != undefined),
};
Enter fullscreen mode Exit fullscreen mode

Now in our form we can lock the fields:

<ItemPicker
  v-model="proxy.item"
  :loading="currentlySetting == 'item'"
  :disabled="isProcessing"
/>
Enter fullscreen mode Exit fullscreen mode

Or just put an overlay on the whole form or even screen. The best thing is that we don’t even need to care about it in the setters, it’s wrapped inside.

Let’s summarize the code

export type ProxySetters<T extends object> = Partial<{
  [K in keyof T]: (target: T, value: T[K]) => Partial<T> | Promise<Partial<T>>
}>;

export function toProxy<T extends object>(opt: {
  target: Ref<T>;
  setters: ProxySetters<T>;
}) {
  // <(string & {})> -- a trick for "any string, but still suggest the keys"
  const currentlySetting = ref<keyof T | (string & {})>();

  function setFields(toSet: Partial<T>) {
    opt.target.value = {
      ...opt.target.value,
      ...toSet,
    };
  }

  const proxy = computed({
    get() {
      return new Proxy(opt.target.value, {
        get(target: T, key: string) {
          return target[key as keyof T];
        },
        set(target: T, key: string, value: any) {
          if (currentlySetting.value) return false;
          const setterFn = opt.setters[key as keyof T];
          if (setterFn) {
            currentlySetting.value = key;
            const toSet = setterFn(target, value);
            if (toSet instanceof Promise) {
              toSet
                .then(setFields)
                .finally(() => (currentlySetting.value = undefined));
            } else {
              setFields(toSet);
              currentlySetting.value = undefined;
            }
          } else {
            opt.target.value[key as keyof T] = value;
          }
          return true;
        },
      });
    },
    set(v) {
      opt.target.value = v;
    },
  });

  return {
    proxy,
    currentlySetting,
    isProcessing: computed(() => currentlySetting.value != undefined),
  };
}
Enter fullscreen mode Exit fullscreen mode

Why and how it would work?

How I mentioned before, when the proxy.value is accessed, it returns a new Proxy. At this level it acts list like any computed returning an object. The returned object will be reactive… in some way, but you shouldn’t see any difference. There is only one getter and one setter here, but inside it returns the field from reactive object: proxy.value.name is equivalent to calling a function that will return the same thing. Therefore, the dependency will be mounted.

The setter… it depends on the strategy. In case of opt.target.value[key as keyof T] = value; there is no creating any new instance, but the reactivity should notify all this field’s listeners that it changed its value.

If the setFields will be called, there will be new instance, then the computed will be outdated and will return new value. It will cause rerender of every component that uses that instance directly. It shouldn’t be any problem anyway.

Also it is perfectly safe to use alongside with the original target, omiting the Proxy mechanism, if not required (however, I would avoid that, it’s not why we implement that thing, to accidentally assign something omiting the setter!)

You can expand the opt argument with another options to let developer choose the strategy on specific places.

Async setters

They are a bit tricky…Let’s take a case where we put a setter on a text field, that checks if every character is lowercase, let’s have a ref:

const item = ref({
  id: 1,
  name: "lorem",
  description: "ipsum",
});
Enter fullscreen mode Exit fullscreen mode

and make a proxy:

const { proxy, isProcessing } = toProxy({
  target: item,
  setters: {
    name: async (obj, val) => {
      if (val == val.toLowerCase()) {
        return { name: val };
      }
      // a function that resolves after 500ms
      await delay(500);
      return {
        name: val.toLowerCase()
      }
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

The problem is that isProcessing will be set to true, because we have here a few Vue’s ticks. The name setter here is itself async, therefore it will return Promise. It means the currentlySetting won’t be set back to undefined in the same tick. It means the template will be in the meantime rerendered and we might lose the focus on the input element.

It shouldn’t be a problem for dropdown pickers, but for text inputs it will be critical. We should make an old-fashion function that just returns a Promise:

const { proxy, isProcessing } = toProxy({
  target: item,
  setters: {
    name: (obj, val) => {
      if (val == val.toLowerCase()) {
        // returned synchronically, in the same tick
        return { name: val };
      }
      // return a Promise
      return delay(500).then(() => ({
        name: val.toLowerCase(),
      }));
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Now if everything will be fine, no uppercase letter would appear, it will return a synchronous object. Otherwise, it will return a Promise and will have to wait for it to end.

Dialogs and more complex controllers

Using the async setters you can ask user for something, maybe user wasn’t aware of the consequences of flipping single switch? Let’s say we have a form, where we have a switch and a list of elements. If the switch goes off, it will remove all elements. We then just can’t use the watch method here, because we can’t allow the change to happen, if user won’t allow that! It’s possible to solve with a simple async setter (using Quasar’s Dialog plugin):

setters: {
  enabled: (obj, enabled) => {
    if (enabled || obj.items.length == 0) return { enabled };
    return new Promise((resolve) => {
      Dialog.create({
        title: 'Confirm',
        message: 'This action will remove all items in list',
        cancel: true,
        persistent: true
      }).onOk(() => {
        resolve({ enabled, items: [] })
      }).onCancel(() => {
        resolve(null);
      });
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case it either updates the enabled field, if it’s enabling or the list is empty, or asks users if they are aware of the consequences first.

While you have to provide setters in the exact form, it doesn’t need to be that simple. You can write complex controllers:

// global utility
function ask<T extends object>(
  title: string,
  message: string,
  onOk: () => Partial<T>
): Promise<Partial<T> | null> {
  return new Promise((resolve) => {
    Dialog.create({
      title,
      message,
      cancel: true,
      persistent: true,
    })
      .onOk(() => {
        resolve(onOk());
      })
      .onCancel(() => resolve(null));
  });
}

function useItemForm(item: Ref<Item>) {
  // loose utilities and setters
  function setEnabled(enabled: boolean) {
    return { enabled };
  }

  // can be defined separately
  const setters = {
    enabled: (obj, enabled) => {
      if (enabled || obj.items.length == 0) return setEnabled(enabled);
      return ask<Item>(
        "Confirm",
        "This action will remove all items in list",
        () => setEnabled(enabled)
      );
    },
  } satisfies ProxySetters<Item>;

  return toProxy({
    target: item,
    setters,
  });
}
Enter fullscreen mode Exit fullscreen mode

We can easily test it separately from Vue components, making the testing much faster and easier. Notice also that setters have no dependencies on Vue, which mean you can share this code with your Node.js backend!

Chaining setters

We can still chain the setters here. Of course, there is a risk of making infinite loop here, so be careful to avoid that. Maybe if we set the category, we just know we need to clean the item. It’s still not wise to allow to make circular dependency between setters, but you can make some easier. Having setters declared separately, like above (with satisfies, not setters: ProxySetters<T>), we can just call another setter with changed object:

 const setters = {
  // ... other setters
  item: async (obj, item) => {
    const setCategory = item?.category.id != obj.category.id 
      ? await setters.category(
          {...obj, item}, // we can fake the original object with overwritten values
          item?.category ?? null // provide a value to set
        ) 
      : {}; // no need to change anything
    return {
      ...setCategory, //  could also change the `item`, or literally nothing
      item, // but we set it here anyway
    };
  },
} satisfies ProxySetter<Type>;
Enter fullscreen mode Exit fullscreen mode

We might not know if changing category shouldn’t change also something else (or it’s logic is more complicated and we don’t want to repeat it). That way, whatever else it changes, it will change. Changing category could require asking user for confirmation, se we should make it async (it’s not a text field, so it shouldn’t be a problem).

And still, in this case, I wouldn’t recommend calling item setter from category setter, as it might cause stack overflow. It would be better here to avoid calling other setters, if changing is not required.

On the way, we can fake the original item, simulating some changes being done already. We might call two setters one after another, and pass to the second one the modified object with applied changes from the first setter.

No actual change on original item the Ref is referring to will happen, until the original setter wouldn’t return the object with changes to perform. We can also perform a validation before saving the changes on the original object and reject them, if validator would find any problem.

Two strategies at the same time

What if we want to use both strategies at the same time? Proxy is a magical object in which you can program both getters and setters for any key, it is just a virtual interface for an actual object. We could easily manipulate it, by adding another set of fields, prefixed with $, assuming there aren’t any already, it should work fine. We will need to add a cast:

return new Proxy(opt.target.value, {
  /// ...
}) as T & {
  [K in keyof T as `$${string & K}`]: T[K];
};
Enter fullscreen mode Exit fullscreen mode

Define the actual setter strategies:

function setFields(toSet: Partial<T> | null) {
  if (toSet == null) return;
  Object.assign(opt.target.value, toSet);
}

function setByReassign(toSet: Partial<T> | null) {
  if (toSet == null) return;
  opt.target.value = {
    ...opt.target.value,
    ...toSet,
  };
}

Enter fullscreen mode Exit fullscreen mode

We will need to modify our getter too:

get(target: T, key: string) {
  if (key.toString().startsWith('$')) {
    return target[key.substring(1) as keyof T];
  }
  return target[key as keyof T];
},
Enter fullscreen mode Exit fullscreen mode

and about setter, we need to first decide which strategy should be used:

set(target: T, _key: string, value: any) {
  const reassign = _key.startsWith('$');
  const setter = reassign ? setByReassign : setFields;
  const key = reassign ? _key.substring(1): _key;
  // ...
Enter fullscreen mode Exit fullscreen mode

And later replace setFields with setter.

Now if the original object had field name, it will have also a field called $name, which getter will return just name, and setter will update the object by replacing it’s instance.

But seriously… it would be better to just return 2 types of proxies. This is just an example of what proxies are able to do.

Track fields that changed

Another nice feature we can implement here, is tracking the fields that changed. Having a function being called on every set, we can track fields that changed and mark them on form or even allow to undo the change.

We would need to store the original object first, with structuredClone, then in setters setFields and setByReassign we would have to call this:

const original = structuredClone(opt.target.value);
const changedFields = ref(new Set<string>());

// this one we will call in setters:
function setChanged(toSet: Partial<T>) {
  for (const [key, value] of Object.entries(toSet)) {
    if (value != original[key]) changedFields.add(key);
    // if it's the same as original, unmark that it changed
    else changedFields.delete(key);
  }
} 

// this one we will just expose:
function restoreField(key: keyof T & string) {
  if (!changedFields.value.has(key)) return;
  proxy.value[key] = original[key]; //call the setter
}
Enter fullscreen mode Exit fullscreen mode

The new setter will update the changedFields status. That way we could inform user that nothing changed in the form.

Presented listener won’t work fine, if the component will be recreated or created twice and the value change will happen outside the proxy. It also doesn’t track changes in nested objects.

Ignore field with Symbol

Sometimes writting setters, we will have to either set other field or not. We could just return a partial without that field, but sometimes it would mean returning 2 different objects, splitting the code into two branches.

Other idea is to return the field as undefined value, which would mean “no fiels”, but what if in that object undefined would be a valid value, along null and any other? JS offers an unique objects that are their own types: Symbols. Let’s create a symbol called IgnoreField:

export const IgnoreField: unique symbol = Symbol("ignore");
Enter fullscreen mode Exit fullscreen mode

Now if we would want to ignore the field, we would just return this symbol at the field. We will need to redefine our types, replace Partial<T> (in ProxySetters) with:

export type ProxyPartialSetter<T extends object> = {
  [K in keyof T]?: T[K] | typeof IgnoreField;
};
Enter fullscreen mode Exit fullscreen mode

It means: object, that is essentially a Partial of T, but allows to assign FieldIgnore at any field. Next, we will need to write a function that will clear the returned object from fields containing that setter:

function clearupSetters<T extends Object>(toSet: ProxyPartialSetter<T>): Partial<T> {
  const copy = {...toSet};
  for(const key of Object.keys(copy)) {
    if (copy[key as keyof T] == IgnoreField) {
      delete copy[key as keyof T];
    }
  }
  return copy as Partial<T>;
}
Enter fullscreen mode Exit fullscreen mode

Sure, functional purists will hate me now, so now a functional approach:

function clearupSetters<T extends Object>(
  toSet: ProxyPartialSetter<T>
): Partial<T> {
  return Object.fromEntries(
    Object.entries(toSet).filter(([key, value]) => value != IgnoreField)
  ) as Partial<T>;
}
Enter fullscreen mode Exit fullscreen mode

It’s slower (at the time of Chrome 115), but if you really put “readibility” of a 5-lines function above performance, then it’s up to you. Also, the first variant without copying the object is even faster, so if you don’t care what happens to this object and don’t want to lose additional nanoseconds, feel free to avoid copying it before removing fields.

Next, we need to modify our setters:

function setFields(toSet: ProxyPartialSetter<T> | null) {
  if (toSet == null) return;
  Object.assign(opt.target.value, clearupSetters(toSet));
}
Enter fullscreen mode Exit fullscreen mode

And that’s it, now we can write setters like:

const setters = {
  item: (obj, item) => ({
    item,
    category: item?.category?.id == obj.category?.id 
      ? IgnoreField // nothing will be changed
      : item?.category
  })
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Just as wrapping the arrays, we can wrap an object in a Proxy and write our own setters, adding some more functionalities, like locking whole form when asynchronous setters are working. The performance impact of this solution is negligible, especially if we won’t use full instance overwrite and we won't use setters for text fields.

With this solution, we can easily handle async setters without the need to implement it in each setter. All that without having to care about it in the template.

This is just the beginning. We could modify it to return much more complex structures, nested proxies with their own setters etc.

Top comments (0)