The declarative approach in Vue 3, think twice before using `watch` or mutating your state
Tags: vue, declarative
Published: 2023-11-03 19:59:32

## What does "declarative" mean?

"Declarative" means that we declare what results we want to achieve, not how. Of course, we still need to implement how it should happen, but we separate those two things: we separately define the state and define what comes out of it.

That's how the thing should work in Vue: **State ⇒ Calculated intermediate states ⇒ Result**. We should keep the mutable state as simple as possible, with as little dependency on each other as possible. If there is a dependency, we should consider, if it should be a result of something else, and setting it should change other conditions, so eventually we will get the desired result.

In terms of Vue, it would mean that we define "what we want to achieve", using reactive data, producing results, instead of mutating state.

## A simple example

Let's do a task: "Display a square given by user number". With Vanilla JS we don't have much choice, it would need to look like this:

```typescript
const input = document.querySelector("input");
const result = document.querySelector(".result");

input.addEventListener("change", (event) => {
  const value = parseFloat(event.target.value) || 0;
  result.textContent = `${value * value}`;
});
```

With Vue, we receive the values in `ref`s, we can listen to the state changes, and just update the state, the renderer will update the DOM, so we will just focus on the script side:

```typescript
const input = ref(""); // binded to <input> via v-model
const result = ref(0); // just displayed
// logic
watch(input, (value) => {
  const a = parseFloat(value) || 0;
  result.value = a * a;
});
```

Okay... simple... Now the next task is to multiply two numbers given by users. And here comes this abomination:

```typescript
// from <input>
const inputA = ref("");
const inputB = ref("");
// to display in HTML:
const result = ref(0);

// logic
watch(inputA, (value) => {
  const a = parseFloat(value) || 0;
  const b = parseFloat(inputB.value) || 0;
  result.value = a * b;
});

watch(inputB, (value) => {
  const b = parseFloat(value) || 0;
  const a = parseFloat(inputA.value) || 0;
  result.value = a * b;
});
```

Oh... My... Sure it works, but... How do we know what `result` is actually? It's a loose state that should be, as the name says, a result of something, but we need to look for what it does in code somewhere else. Let's represent it on a diagram (the dashed line represents usage of a value, not actual dependency):


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/r9ow7x1bwp0jdqly3e9m.png)



Okay, maybe I've exaggerated the problem here, but it was a simple example, there could be a much more complicated state where it wouldn't be that obvious that such a thing happens, you'll see it in the next example. Here, it would probably be solved with this code:

```typescript
watch([inputA, inputB], (valueA, valueB) => {
  const a = parseFloat(valueA) || 0;
  const b = parseFloat(valueB) || 0;
  result.value = a * b;
});
```

Which would be simplified into this:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/f65tlt4whxeom88x6k81.png)



Much better, but but still, we can't be sure that somewhere there wasn't the original `watch` left mutating `resutlt`. The problem with this approach is that `result` can be set somewhere and tracking it may not be easy. Much better would be to define it as a `computed`:

```typescript
const result = computed(() => {
  const a = parseFloat(inputA.value) || 0;
  const b = parseFloat(inputB.value) || 0;
  return a * b;
});
```


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/htobailzl7wercm3anri.png)



That way we can be sure of what `result` says, and how is computed, and that nowhere else its value would be overwritten. What's the difference here between `watch` and `computed`, if the result is the same?

**The first one is more imperative**: you react to something being changed and mutate a different state.

**The second one is declarative**: you declare `result` as a result of multiplying two numbers. You don't care why or where they are changing.

Sure, it's a simple example where it's easy to track the watchers, but it might have much more dependencies. Anyway, the advantages of this approach:

* `result` is read-only, as it should be
    
* we know exactly what `result`'s value is, where it comes from
    
* automatically tracks used dependencies required to calculate it
    
* will be lazily calculated exactly when it's needed (no performance penalty, when it's not used)
    
* easy to expand with further dependencies
    
* can be easily wrapped and exported into another file as a reusable composable function
    

But what's most important: the result state will result directly from the input state.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">Sure, this approach might be suboptimal compared to the watch, but I don't think anyone would care about the difference between 10ns and 20ns.</div>
</div>

## Building URL query with filters, sorting, and pagination

While the filters and paginations are rather simple, you don't want to keep keys like `sort[name]` in your code. The `sort[x]` is often unique.

Let's see how bad we can it make:

```typescript
const queryItems = ref<Record<string, string>>({});
const sortKey = ref("name");
const order = ref<"asc" | "desc" | null>("asc")

watch(sortKey, (newKey, oldKey) => {
  // when key changes
  if (oldKey) { // delete old key, if were any
    delete queryItems.value[`sort[${oldKey}]`];
  }
  order.value = "asc"; // reset the order
  // assign the new query key
  queryItems.value[`sort[${newKey}]`] = order.value; 
}, { immediate: true });

watch(order, (newOrder) => {
  // when order changes
  if (newOrder) {
    // update the query item value
    queryItems.value[`sort[${sortKey.value}]`] = newOrder;
  } else {
    // if none, then just disable it by deleting it
    delete queryItems.value[`sort[${sortKey.value}]`];
  }
});
```

I'll omit the non-reactive dependencies now because everything would have to be connected:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e1lyfdzl0kgiwb0w6c0z.png)



Please, if you see a code like that in your Code Review, **reject it**. For multiple reasons:

* using `delete` (explained below)
    
* mutating a state in a watch that is watched by another watch
    
* create and delete a dynamic key in `queryItems`, no real reference to anything
    
* multiple watches mutating a state, just no! It's making a mess in the code, look at the diagram!
    
* no validation of type for `sort[x]`, sure, you could add a pattern in TypeScript for `queryItems`, but... no.
    

What can go wrong?

* for some reason, `sort[x]` may remain unremoved from `queryItems`
    
* might get into an infinite loop
    
* Hot Module Reload could make your life hell here during the development
    

<div data-node-type="callout">
<div data-node-type="callout-emoji">⭐</div>
<div data-node-type="callout-text">okay, I might be a bit oversensitive here, because sometimes <code>delete</code> is the fastest way to achieve something. But seriously, avoid using that, especially in reactive data. It's a very... uncivilized tool</div>
</div>

Notice that if sortKey changes while the order was `"desc"` or `null`, the second watch will be called, mutating the queryItem redundantly. Someone might also get an idea of setting `sortKey` to `null`, when the order is set to `null`. You don't want to debug it in runtime if something bad will happen.

First, the `sortKey` and `order` are connected and it would be nice to keep them in one object:

```typescript
type Sort = {
  key: string;
  order: "asc" | "desc";
};

const sort = ref<Sort | null>({
  key: string;
  order: "asc";
});
```

That way we can simply disable sorting by setting it to null. Then, the actual filters will be put into a `ref` called `filters`, only for filters, no sorting, no paginations. The same thing we can do with pagination, also keep it in a separate object: `{page: number, itemPerPage: number} | null`. And then, at the end, merge them all in a computed using spread operator:

```typescript
const queryItems = computed(() => {
  // a helper object
  const sortQuery = sort.value ? {
    [`sort[${sort.value.name}]`]: sort.value.order
  } : null;

  const query = {
    ...filters.value,
    ...sortQuery,
    ...pagination.values
  }
  return new URLSearchParams(Object.entries(query)).toString();
});

const { data: list, pending } = useFetch(`/list?${queryItems.value}`);
```


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b7pzsehnhytcs6ojx4is.png)



We keep our hands clean with the dirty `sort[name]` key, so it's easier to debug it. If `sortKey` or `pagination.value` will result in being `null`, and nothing bad will happen (no fields will be added).

You might also want to filter out `null` and `undefined` values from the `query` object: `Object.entries(query).filter(([k,v]) => v != null)` .

Clean and simple, we have an initial state and produce a result. In this case, it's nice to create a composable helper:

```typescript
export function useQueryBuilder(opt: {
  filters?: MaybeRef<Record<string, string>>,
  sort?: MaybeRef<Sort | null>,
  pagination?: MaybeRef<Pagination | null>,
}) {
  return computed(() => {
    const $sort = unref(sort);
    const sortQuery = $sort ? {
      [`sort[${$sort.name}]`]: $sort.order
    } : null;

    const query = {
      ...unref(filters),
      ...sortQuery,
      ...unref(pagination),
    }

    return new URLSearchParams(
      Object.entries(query).filter(([k, v]) => v != null)
    ).toString();
  });
};
```

Notice that instead of using `.value` we use `unref`, because we might want to allow to set filters, sort, or pagination that are not reactive, `unref` allows to use either. You can also make it a whole URL builder with type checking.

## A bit more complicated example

The user switches between 2 versions of a form. How to organize that?

Well, at first, we would need to define the `ref` containing the form: one of two versions, let's call them `Form1` and `Form2`, with the default version `Form1` . How bad can it be done?

```xml
<AccountTypeSwitch @change="setType"/>
```

```typescript
type AccountType = "personal" | "company"
const form = ref(/*  */);

const personalFields = ["firstName", "lastName", "birthdate"];
const companyFields = ["companyName", "taxNumber"];

function setType(newAccountType: AccountType) {
  form.value.type = newAccountType;
  if (newAccountType == "personal") {
    companyFields.forEach(field => {
      delete form.value[field]
    });
    personalFields.forEach(field => {
      form.value[field] = "";
    });
  } else {
    personalFields.forEach(field => {
      delete form.value[field]
    });
    companyFields.forEach(field => {
      form.value[field] = "";
    });
  }
}

async function send() {
  const { type, ...toSend } = form.value;
  await register(toSend);
}
```

First, there is no synchronization between currently selected account type, and what `AccountTypeSwitch` displays as current.

Second, the type is a mess. Good luck with typing it in TypeScript.

The other solution was to keep everything in the object, without removing/reinitializing the fields in the setter and then remove unwanted fields in the function `send`... this is just terrible.

We have an additional field `type` that has to be removed before sending to the API, one big object in which we are deleting fields dynamically... Even if we change the `delete` to overwriting the object without these fields it will be terrible.

### How to fix this mess?

The best solution would be just to do 2 separate components with both forms, but what if it's just a part of a bigger form? We would need to somehow merge the data before sending.

Right, merge before sending, don't keep it all together whole the time! **Divide and conquer!** Let's say that it's a registration form with choosing an account type: personal or company.

If you'll try to keep the form in a type, that keeps both options, you will make a huge mess. Split the form into smaller portions of data, then merge them together before sending, depending on selected choices.

```typescript
type AccountType = "personal" | "company"
const accountType = ref<AccountType>("personal");

const accountForm = ref(/* */);
const personalForm = ref(/* */);
const companyForm = ref(/* */);

const resultForm = computed(() => {
  const accountTypeForm = 
    accountType.value == "personal" 
      ? personalForm.value
      : companyForm.value;
  return {
    ...accountForm.value,
    ...accountTypeForm,
  }
});
```

It's still clean. We can also take a different approach: we can have a `ref` that will receive a form to add, but then, we need to give control to the component with the form. What I mean, is to create `RegisterPersonalForm.vue` and `RegisterCompanyForm.vue`, each would contain its own part of the form, and emit the ready part of the form:

```xml
<keep-alive>
  <RegisterPersonalForm
    v-if="accountType == 'personal'"
    @stored="accountTypeForm = $event"
  />
  <RegisterCompanyForm
    v-else-if="accountType == 'company'"
    @stored="accountTypeForm = $event"
  />
</keep-alive>
```

```typescript
const accountTypeForm = ref<RegisterPersonal | RegisterCompany>();

const resultForm = computed(() => {
  return {
    ...accountForm.value,
    ...accountTypeForm.value,
  }
});
```

We can't use `accountTypeForm` with `v-model` for both components, because the types will be incompatible: what if the user filled some for the personal option, then switched to the company?

That's why the form data will be stored inside those components, and to not lose them, we use `<keep-alive>`, they will emit event `stored` when we want... maybe on blur, maybe on any change... Or maybe on the button `NEXT`, if we implement steps. That's also how we can prevent our state in the main component from being invalid.

### But stop here!

There is a catch. If we switch the mode, the event won't be emitted, therefore we will keep old data in the `accountTypeForm`. The newest state is being kept in the components, so listening to the `accountType` change, we won't be able to set the right state in our `accountTypeForm`.

We could add a prop `selectedAaccountType` to those components and when it matches the component type to emit:

```typescript
const props = defineProps<{
  currentAccountType: string;
}>();

watch(() => props.currentAccountType, (type) => {
  if (type == "personal") emit("store", form.value);
})
```

but it would be a bad practice I call *render-driven logic* (logic is controlled by a rendering state). So **let's abandon this idea**.

Switch back to two `ref`s:

```xml
<RegisterPersonalForm
  v-if="accountType == 'personal'"
  v-model="personalForm"
/>
<RegisterCompanyForm
  v-else-if="accountType == 'company'"
  v-model="companyForm"
/>
```

If real-time update is what we want, we simply just use the prop fields (they will be reactive, however, it's considered bad practice and is forbidden by eslint by default), or use a wrapper for fields like `toFormProxy` :

```typescript
const modelValue = defineModel<RegisterPersonal>();
const form = toFormProxy(modelValue);
```

If we want to update the form data on the button click:

```typescript
const modelValue = defineModel<RegisterPersonal>();

const emit = defineEmits({
  stored: (_: RegisterPersonal) => true
});
// we will work on local copy
const form = ref(structuredClone(modelValue.value));

watch(modelValue, (value) => form.value = structuredClone(value));

// on `NEXT` with validation:
function next() {
  try {
    const v = validate(form.value);
    modelValue.value = v;
  } catch(error) {
    notify.error("Validation error")
  }
}
```

We can declare every part of the form like that, or every step. We will keep our state clear, and make it easier to implement the `SKIP` option (just emit `null` like that). Going back to the form's main component that builds the body for the request, we might do it like this:

```typescript
const accountTypeForm = computed(() => {
  if (accountType.value == "personal") {
    return personalForm.value;
  } else if (accountType.value == "company") {
    return personalForm.value;
  }
  throw new Error("Unknown account type");
})

const resultForm = computed(() => {
  return {
    ...accountForm.value,
    ...accountTypeForm.value,
  };
});
```

And again, we have a multi-level requests body builder, where we keep logic to the minimum, solving problems as early as possible.

## Conclusion

Keep things simple. The declarative approach makes it easier to debug and track why things change. You build a result on the given state, not mutate the result state ad hoc, because "it fits here and it should be sent like that".

Every result state should be reconstructable at any moment, without having to recreate every step user made. That way it's easy to find the place, where something goes wrong.