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();

Nothing changed yet, but that's about to change. Let's call updateFullName whenever we edit firstName or lastName.

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 tuff 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>
    `;
});