Part 1a - reactivity (step by step)
This tutorial aims to explain how reactivity is implemented in Vue3. Part 1a is meant to be a “step by step” tutorial, If you know how reactivity works and are looking for “all in one” version, check part 1b.
First of all, let’s start with very simple approach.
const render = () => {
document.getElementById("app").innerHTML = `
<table>
<tr>
<td>firstName</td>
<td>${firstName}</td>
</tr>
<tr>
<td>lastName</td>
<td>${lastName}</td>
</tr>
<tr>
<td>fullName</td>
<td>${fullName}</td>
</tr>
</table>
`;
}
let firstName = "Janusz";
let lastName = "Kowalski";
let fullName = "";
const updateFullName = () => {
fullName = `${firstName} ${lastName}`;
}
updateFullName();
render();
Now, let’s start adding new features to this. First of all, wrapping all our data would be nice.
const render = () => {
document.getElementById("app").innerHTML = `
<table>
<tr>
<td>data.firstName</td>
<td>${data.firstName}</td>
</tr>
<tr>
<td>data.lastName</td>
<td>${data.lastName}</td>
</tr>
<tr>
<td>data.fullName</td>
<td>${data.fullName}</td>
</tr>
</table>
`;
};
const data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
updateFullName();
render();
It would be nice if we didn’t have to call updateFullName
every time we modify firstName
or lastName
.
The way we can achieve that is via Proxy.
For now, let’s also omit render
function, as it won’t change in next examples (at least for some time).
const raw_data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const data = new Proxy(raw_data, {});
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
updateFullName();
render();
```js
Nothing changed yet, but that's about to change. Let's call `updateFullName` whenever we edit `firstName` or `lastName`.
```js
const raw_data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const data = new Proxy(raw_data, {
set(obj, key, value) {
obj[key] = value;
if (["firstName", "lastName"].includes(key)) {
updateFullName();
}
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/set#Return_value
return true;
}
});
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
updateFullName();
render();
Note, that we can’t blindly call updateFullName
, as updateFullName
will modify fullName
field in data, which would cause infinite recursion.
But now we have to specify which fields depends on which. Maybe we can extract that.
const raw_data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const depends = {
firstName: updateFullName,
lastName: updateFullName
};
const data = new Proxy(raw_data, {
set(obj, key, value) {
obj[key] = value;
if (depends[key])
depends[key]();
return true;
}
});
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
updateFullName();
render();
But we probably want to be able to have multiple dependencies.
const raw_data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const depends = {
firstName: [updateFullName],
lastName: [updateFullName]
};
const data = new Proxy(raw_data, {
set(obj, key, value) {
obj[key] = value;
if (depends[key])
depends[key].forEach(cb => cb());
return true;
}
});
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
updateFullName();
render();
That’s cool, but if we could somehow populate dependencies
automatically it would cleanup our code.
Here’s where the “get” part of Proxy can be used.
Note, by using set we don’t add same dependency twice.
const raw_data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const depends = {};
const data = new Proxy(raw_data, {
get(obj, key) {
if (!depends[key])
depends[key] = new Set();
depends[key].add(updateFullName);
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
if (depends[key])
depends[key].forEach(cb => cb());
return true;
}
});
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
updateFullName();
render();
Whenever we “access” a property on our object, we add an effect as a dependency. Very nice, but what if we could auto-detect what effect to run? That’s done in a very quick and dirty way :)
const raw_data = {
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
};
const depends = {};
let currentEffect = null;
const data = new Proxy(raw_data, {
get(obj, key) {
if (currentEffect) {
if (!depends[key])
depends[key] = new Set();
depends[key].add(currentEffect);
}
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
if (depends[key])
depends[key].forEach(cb => cb());
return true;
}
});
const updateFullName = () => {
data.fullName = `${data.firstName} ${data.lastName}`;
};
currentEffect = updateFullName;
updateFullName();
currentEffect = null;
render();
We can abstract some things out, to make it a bit nicer.
let currentEffect = null;
const reactive = (target) => {
const depends = {};
return new Proxy(target, {
get(obj, key) {
if (currentEffect) {
if (!depends[key])
depends[key] = new Set();
depends[key].add(currentEffect);
}
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
if (depends[key])
depends[key].forEach(cb => cb());
return true;
}
});
};
const useEffect = (effect) => {
currentEffect = effect;
effect();
currentEffect = null;
};
const data = reactive({
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
});
useEffect(() => {
data.fullName = `${data.firstName} ${data.lastName}`;
});
render();
First of all, I’ve create a factory, that returns our reactive
objects. We can even include depends
inside to make it even tidier!
Second of all, we can automate some stuff by introducing useEffect
. Note, that due to useEffect
we no longer need a name for updateFirstName
!
But our useEffect
doesn’t have to mutate anything! Since it binds “on access”, we can use it to
fire our function whenever our dependency changes. That means, we can turn our render
function into an effect!
let currentEffect = null;
const reactive = (target) => {
const depends = {};
return new Proxy(target, {
get(obj, key) {
if (currentEffect) {
if (!depends[key])
depends[key] = new Set();
depends[key].add(currentEffect);
}
return obj[key];
},
set(obj, key, value) {
obj[key] = value;
if (depends[key])
depends[key].forEach(cb => cb());
return true;
}
});
};
const useEffect = (effect) => {
currentEffect = effect;
effect();
currentEffect = null;
};
const data = reactive({
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
});
useEffect(() => {
data.fullName = `${data.firstName} ${data.lastName}`;
});
useEffect(() => {
document.getElementById("app").innerHTML = `
<table>
<tr>
<td>data.firstName</td>
<td>${data.firstName}</td>
</tr>
<tr>
<td>data.lastName</td>
<td>${data.lastName}</td>
</tr>
<tr>
<td>data.fullName</td>
<td>${data.fullName}</td>
</tr>
</table>
`;
});
That’s pretty much it! Of course this is very simple and naive implementation, but it explains how stuff works.
We can use Reflect
to get this
to behave nicely, as well as add more handlers to reactive
like has
.
let currentEffect = null;
const reactive = (target) => {
const depends = {};
return new Proxy(target, {
get(obj, key, receiver) {
if (currentEffect) {
if (!depends[key])
depends[key] = new Set();
depends[key].add(currentEffect);
}
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const ret = Reflect.set(obj, key, value, receiver);
if (depends[key])
depends[key].forEach(cb => cb());
return ret;
},
has(obj, key) {
return Reflect.has(obj, key);
}
});
};
const useEffect = (effect) => {
currentEffect = effect;
effect();
currentEffect = null;
};
const data = reactive({
firstName: "Janusz",
lastName: "Kowalski",
fullName: ""
});
useEffect(() => {
data.fullName = `${data.firstName} ${data.lastName}`;
});
useEffect(() => {
document.getElementById("app").innerHTML = `
<table>
<tr>
<td>data.firstName</td>
<td>${data.firstName}</td>
</tr>
<tr>
<td>data.lastName</td>
<td>${data.lastName}</td>
</tr>
<tr>
<td>data.fullName</td>
<td>${data.fullName}</td>
</tr>
</table>
`;
});
