The declarative approach in Vue 3
Think twice before using `watch` or mutate 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}`;
});
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:
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:
// 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):
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;
});
Which would be simplified into this:
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;
});
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 bewe know exactly what
result
's value is, where it comes fromautomatically 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.
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}]`];
}
});
I'll omit the non-reactive dependencies now because everything would have to be connected:
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 anythingmultiple 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 forqueryItems
, but... no.
What can go wrong?
for some reason,
sort[x]
may remain unremoved fromqueryItems
might get into an infinite loop
Hot Module Reload could make your life hell here during the development
delete
is the fastest way to achieve something. But seriously, avoid using that, especially in reactive data. It's a very... uncivilized toolNotice 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";
});
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}`);
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();
});
};
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"/>
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.
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:
<keep-alive>
<RegisterPersonalForm
v-if="accountType == 'personal'"
@stored="accountTypeForm = $event"
/>
<RegisterCompanyForm
v-else-if="accountType == 'company'"
@stored="accountTypeForm = $event"
/>
</keep-alive>
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:
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:
<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
:
const modelValue = defineModel<RegisterPersonal>();
const form = toFormProxy(modelValue);
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")
}
}
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,
};
});
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.