Home Static analysis Blog

Proxy Model; or, The Watcher Alternative

Jacob Hardbower

We know that the abuse of watchers can lead to side effects. The Proxy Model is one solution.

A common abuse of watchers

You've got a form that has a few fields, so you set up one or many refs for user input.

// setup
const model = reactive({
  firstName: '',
  lastName: '',
  likesGrapes: true,
})

function submit() {
  emit('updateUser', model)
}

Your template would resemble the following.

<cw-form @submit="submit">
  <input type="text" v-model="model.firstName" />
  <input type="text" v-model="model.lastName" />
  <input type="checkbox" v-model="model.likesGrapes" />
</cw-form>

This is great, until you need to support multiple data sources.

For example, let's say your user already has a name associated with their account, and you want to pre-fill it. You pass in a prop to your component.

defineProps({ defaults: Object })

Now, you'll need to check if defaults exists, and if they do, have the form values (model) reflect that.

We commonly see two approaches:

  1. Watchers
  2. Lifecycle Hooks (i.e. onMounted)

These two approaches are similar, so we'll use watchers for our example. Here's what we have now.

const props = defineProps({ defaults: Object })

const model = reactive({
  firstName: '',
  lastName: '',
  likesGrapes: true,
})

function submit () {
  emit('updateUser', model)
}

watch(() => props.defaults, () => {
  model = {
    ...model,
    ...props.defaults,
  }
}, immediate: true)

Our model is updated whenever a change is observed on our defaults prop. Yay!

Except now we have two competing sources of data. You can type stuff in the form, but if the default prop changes, the typed in data will be overwritten. This is a side effefct. If that's exactly what you want, then a watcher may be the right choice. This is very rare though, and doesn't line up with the nomenclature we're using. In our example, the defaults are there to prefill the form once.

Before we talk about an alternative method, let's spice it up a bit.

You've got a multi-step form, and with each step you're pushing more and more data into a larger object that will be submitted on the last step. That can be reflected in some wonderful naming:

function submit() {
  emit('updateLargerObject', model)
}

So here's the kicker. Let's say we fill out our name, go to the next step, and then come back to the name step. We obviously want the fields to populate from the largerObject. UH OH. Another source of data!

Do we add another watcher that looks at the new largerObject prop we're now passing in? How do we prioritize the watchers so that the existing name takes precedence over the defaults? EW!

Let's strip this back a bit and see what we're dealing with.

const props = defineProps({
  defaults: Object,
  largerObject: Object,
})

const model = reactive({
  firstName: '',
  lastName: '',
  likesGrapes: true,
})

function submit() {
  emit('updateLargerObject', model)
}

A better way

We need a way to easily set and understand data priority while reducing side effects. We want to prioritize data sources in the following way (ordered by precedence):

  1. User input
  2. largerObject data
  3. defaults data
  4. Form defaults (i.e. likesGrapes: true)

Number 4 may have snuck up on you, but we do need to allow the option for fields to have defaults separate from user "defaults".

Without further ado, let's see the final results.

<cw-form @submit="submit">
  <input type="text" :value="model.firstName" @change="proxyModel.firstName" />
  <input type="text" :value="model.lastName" @change="proxyModel.lastName" />
  <input type="checkbox" :value="model.likesGrapes" @change="proxyModel.likesGrapes" />
</cw-form>

<script setup>
  // imports, etc.

  const props = defineProps({
    defaults: Object,
    largerObject: Object,
  })

  const proxyModel = reactive({})

  const model = computed(() => ({
    firstName: proxyModel.firstName ?? largerObject.fistName ?? defaults.firstname,
    lastName: proxyModel.lastName ?? largerObject.lastName ?? defaults.lastName,
    likesGrapes: proxyModel.lastName ?? largerObject.lastName ?? defaults.lastName ?? true,
  }))

  function submit() {
    emit('updateLargerObject', model)
  }
</script>

I know a lot has changed, so let's break it down.

model: The single source of truth

Our model will be the read-only, single source of truth for the values of our inputs. We don't need to worry about our props once we set up the priority in the computed model.

model is computed based on 3-4 data sources, which are placed in order of their priority. For example, if we have a completely clean form, likesGrapes should be checked because it evaluates to true. If the user is coming back to this form and has defaults, we will look at that. If the user is coming back to this step from a subsequent step, we look at largerObject. Lastly, if the user types something into the form, that takes precedence over everything (see next section).

proxyModel: Live user input lives here

The proxyModel is a place for your temporary form input data to live. As the user types, the proxyModel is updated. Remember, model is read-only, so we write to proxyModel, and model will be updated to reflect these changes.

value/change: The expanded v-model syntax

Because model is computed, and therefore read-only, we can't use it in v-model. We know that v-model is really just a getter/setter combined into a single directive, so we can split it up instead.

The value, or "read" portion of the input looks at model, the single source of truth.

The change, or "write" portion points to our proxy.

Note: For radio button inputs, you'll need to bind to the checked attribute instead of value.

So as you type, model is constantly being updated through the proxy, and the input immediately reflects that change by reading model. It's the circle of life.

Recap

The proxyModel method allows you maintain a single source of truth while managing an endless number of prioritized data sources.

Another great feature of this pattern is the ability to rename variables in situations where your API might want underscores or bad naming, but you want camelcase and good naming. Just make sure your syntax matches inside the computed.

const model = computed(() => ({
  firstName: proxyModel.firstName ?? largerObject.firstname ?? defaults.firstname,
  lastName: proxyModel.lastName ?? largerObject.lastname ?? defaults.lastName,
  likesGrapes: proxyModel.lastName ?? largerObject.likes_grapes ?? defaults.lastName ?? true,
}))

function submit() {
  emit('updateLargerObject', {
    firstname: model.value.firstName,
    lastname: model.value.firstName,
    likes_grapes: model.value.likesGrapes,
  })
}

Keep in mind, there are a lot of unique cases you'll run into, and they may require some extra thought about data priority. In general though, making your model computed (read-only) will help you avoid many side effects.

← Back to blog
Home Static analysis Blog Storybook Made with ❤️, by us.