DEV Community

jkonieczny
jkonieczny

Posted on

The declarative approach in Vue 3, think twice before using `watch` or mutating your state

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:

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}`;
});
Enter fullscreen mode Exit fullscreen mode

With Vue, we receive the values in refs, 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:

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;
});
Enter fullscreen mode Exit fullscreen mode

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

// 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;
});
Enter fullscreen mode Exit fullscreen mode

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

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:

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

Which would be simplified into this:

Image description

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:

const result = computed(() => {
  const a = parseFloat(inputA.value) || 0;
  const b = parseFloat(inputB.value) || 0;
  return a * b;
});
Enter fullscreen mode Exit fullscreen mode

Image description

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.

💡
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.

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:

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}]`];
  }
});
Enter fullscreen mode Exit fullscreen mode

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

Image description

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


okay, I might be a bit oversensitive here, because sometimes delete is the fastest way to achieve something. But seriously, avoid using that, especially in reactive data. It's a very... uncivilized tool

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:

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

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

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:

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}`);
Enter fullscreen mode Exit fullscreen mode

Image description

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:

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();
  });
};
Enter fullscreen mode Exit fullscreen mode

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?

<AccountTypeSwitch @change="setType"/>
Enter fullscreen mode Exit fullscreen mode
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);
}
Enter fullscreen mode Exit fullscreen mode

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.

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,
  }
});
Enter fullscreen mode Exit fullscreen mode

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:

<keep-alive>
  <RegisterPersonalForm
    v-if="accountType == 'personal'"
    @stored="accountTypeForm = $event"
  />
  <RegisterCompanyForm
    v-else-if="accountType == 'company'"
    @stored="accountTypeForm = $event"
  />
</keep-alive>
Enter fullscreen mode Exit fullscreen mode
const accountTypeForm = ref<RegisterPersonal | RegisterCompany>();

const resultForm = computed(() => {
  return {
    ...accountForm.value,
    ...accountTypeForm.value,
  }
});
Enter fullscreen mode Exit fullscreen mode

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:

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

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

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 refs:

<RegisterPersonalForm
  v-if="accountType == 'personal'"
  v-model="personalForm"
/>
<RegisterCompanyForm
  v-else-if="accountType == 'company'"
  v-model="companyForm"
/>
Enter fullscreen mode Exit fullscreen mode

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 :

const modelValue = defineModel<RegisterPersonal>();
const form = toFormProxy(modelValue);
Enter fullscreen mode Exit fullscreen mode

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

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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,
  };
});
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)