Vue.js reactivity explained

Hello!

Abdelrahman Awad

Software Engineer @Baianat

Open-source contributor and creator of vee-validate

Reactivity?

Reactivity is the ability to track changes and perform actions based on said changed

In Vue.js, the component state is reactive

The component state includes the "data", "computed", and "props" properties.

Example on reactivity in Vue.js

<template>
  <div>
    <span>{{ displayName }}</span>
  </div>
</template>

<script>
export default {
  props: {
    title: {
      type: String
    }
  },
  data: () => ({
    firstName: '',
    lastName: ''
  }),
  computed: {
    // How does Vue know that any of these properties changed?
    displayName () {
      return `${this.title}. ${this.firstName} ${this.lastName}`;
    }
  }
};
</script>

Reactivity in JavaScript

let x = 10;

function double () {
  return x * 2;
}

double(); // 20
x = 5;

We would need to keep calling 'double' to get the new value

ES5 getters

let x = 10;
let state = {};
Object.defineProperty(state, 'doubleX', {
  get () {
    return x * 2;
  }
})

state.doubleX; // 20
x = 5;
state.doubleX; // 10

Now we have a 'live' reference that returns the new value every time

This is not enough, we still need to 'get' the value.

We merely changed the style.

Watchers

// You can pass a function to the vm.$watch
// How does Vue know that it uses firstName and lastName?
this.$watch(() => {
  return `${this.firstName} ${this.lastName}`;
}, (val, oldVal) => {
  console.log(val, oldVal);
});

It is not magic, it is still JavaScript.

You can watch a function expression with Vue.js watch API

The observer pattern

An object, called the subject, maintains a list of its dependents, called observers, and notifies them automatically of any state changes.

implementing watch()

1. We need to observe objects

const state = {
  x: 10
};

function observe (obj) {
  // ???
}

const observableState = observe(state);

2. We need to watch for changes

const double = () => {
  return state.x * 2;
};

function watch (exp, callback) {
  // ???
}

watch(double, val => {
  console.log(val);
});

observe()

// Takes an object and returns an observable one.
function observe (obj) {
  const observable = {};

  Object.keys(obj).forEach(key => {
    // Set the initial value under a private key.
    observable[`_${key}`] = { value: obj[key] };

    // Expose getters/setters to proxy the underlying value.
    Object.defineProperty(observable, key, {
      get () {
        return this[`_${key}`].value;
      },
      set (val) {
        this[`_${key}`].value = val;
      }
    });
  });

  return observable;
}

Something is still missing 🤔

watch()

function watch (expression, callback) {
  // Do something with the expression.

  // Run the callback once the expression dependencies change.
}

We hit a roadblock, just by executing the expression we have to be able to tell which properties did it use!

We can call these expressions: observers.

We need to "collect" those observers and have the subject notify them.

observe(): revised

// Simple function to wrap values in.
function wrap (value) {
  return {
    value,
    deps: [],
    notify () {
      // runs the observers
      this.deps.forEach(dep => dep());
    }
  };
}

We need to wrap values as subjects and have them notify their observers.

observe(): revised

// ...
Object.keys(obj).forEach(key => {
  // Set the initial value under a private key.
  observable[`_${key}`] = wrap(obj[key]);

  // Expose getters/setters to proxy the underlying value.
  Object.defineProperty(observable, key, {
    get () {
      const subject = this[`_${key}`];
      // collecting dependents
      subject.deps.push(
        // 😕 ???
      );

      return subject.value;
    },
    set (val) {
      const subject = this[`_${key}`];
      subject.value = val;
      // notify observers.
      subject.notify();
    }
  });
});
// ...

Collecting dependcies

The key is relying on an important but often ignored aspect of JavaScript ... 

JavaScript is single-threaded, which means there can only be 1 line of code running in any given moment...

By extension, There can only be 1 function running in any given moment.

🥁🥁🥁🥁

watch()

// I want this to be hoisted.
var activeFn;

// watches an expression and runs the callback.
function watch (expression, callback) {
  activeFn = expression;
 
  expression();
 
  activeFn = undefined;
}

observe()

// ...
Object.defineProperty(observable, key, {
  get () {
    const subject = this[`_${key}`];
    // collecting dependents
    if (activeFn) {
      subject.deps.push(
        activeFn
      );
    }

    return subject.value;
  },
  set (val) {
    const subject = this[`_${key}`];
    subject.value = val;
    subject.notify();
  }
});
//...

Usage

const state = observe({
  x: 10
});

watch(() => {
  console.log('I ran!');
 
  return state.x * 2;
}, () => {});

state.x = 1;
state.x = 2;
state.x = 3;

// we should see "I ran 4x times"

Running Callbacks

// watches an expression and runs the callback.
function watch (expression, callback) {
  activeFn = expression;
  // not a very good way to do it, but 🤷‍
  expression.fn = callback;
  // by running the expression, we ran our `get` handlers.
  expression();
  activeFn = undefined; // must be done!
}

function wrap (value) {
  return {
    // ...
    notify () {
      // in our set handler
      obs.deps.forEach(dep => {
        const newVal = dep();

        if (dep.fn) {
          // pass the new value to the attached handler.
          dep.fn(newVal);
        }
      });
    }
  }
}

Rendering Demo

// state object
const state = observe({
  count: 0
});

// render-fn
const render = () => {
  return `
	<h1>
	  Count is: ${state.count}
	</h1>
  `;  
};

// start watching.
watch(render, (value) => {
  document.body.innerHTML = value;
});

// run the first render
document.body.innerHTML = render();

setInterval(() => {
  state.count++;
}, 500);

Rendering Demo

What about computed properties?

We can make a watcher become a computed prop

by adding state, we will need a wrapper for that.

function makeComputed (exp) {
  const wrapper = observe({
    value: exp()
  });

  watch(exp, val => {
    wrapper.value = val;
  });

  return wrapper;
};

const state = observe({
  x: 10
});

const double = makeComputed(() => state.x * 2);

Caveats

1. Duplicate Dependencies

const state = observe({
  firstName: '',
  lastName: ''
});

const exp = () => {
  state.firstName;
  state.firstName;
  state.firstName;
};

// state.firstName will be collected 3 times !!!
watch(exp);

Caveats

1. Duplicate Dependencies: Solution

// ...
// ...
// Set the initial value under a private key.
observable[`_${key}`] = {
  value: obj[key],
  deps: new Set(), // instead of array
  notify () {
    // ...
  }
};

// ...
// ...
// ...

// In the gettter
if (activeFn) {
  subject.deps.add(activeFn); // instead of push
}

Caveats

2. Objects

0
 Advanced issues found
 
const state = observe({
  user: {
    firstName: '',
    lastName: '',
    social: {
      fb: '....',
      tt: '....'
    }
  }
});

// Can be observed!
state.user.firstName = 'Amr';

let user = state.user; 

// Will not be observed!
user.firstName = 'Abdelrahman';

// What about this?
user.social.fb = '....';

Caveats

2. Objects: Solution

0
 Advanced issues found
 
Object.keys(obj).forEach(key => {
  let prop = obj[key];
  // because typeof null = 'object' (╯°□°)╯︵ ┻━┻
  // typeof [] = 'object'
  if (typeof prop === 'object' && prop && !Array.isArray(prop)) {
    // recursive.
    prop = observe(prop);
  }
  
  observable[`_${key}`] = wrap(prop);
  // ...
  // ...
}

Caveats

3. Arrays

0
 Advanced issues found
 
const state = observe({
  items: []
});

// Should observe appended items!
state.items.push({ value: 0 });

// Should observe properties on each items.
state.items[0].value = 10;

// Should observe node removals.
state.items.splice(0, 1);

Caveats

3. Arrays: Solution

0
 Advanced issues found
 
  Object.keys(obj).forEach(key => {
    let prop = obj[key];
 
    if (Array.isArray(prop)) {
      // convert all items to observables first!
      prop = prop.map(observe);
      obj.push = function pushReactive (...args) {
        // convert all added items to reactive items.
        let reactiveItems = args.map(observe);
        const result = Array.prototype.push.call(prop, ...reactiveItems);
        observable[`_${key}`].notify(); // notify dependencies

        return result;
      }
      
      obj.splice = function spliceReactive (...args) {
        let result = Array.prototype.splice.call(prop, ...args);
        observable[`_${key}`].notify(); // notify dependencies
        
        return result;
      }
    }
    
    // ...
  });

Full Demo

More caveats

const state = observe({
  isLoading: true,
  loadedMessage: 'Hello',
  loadingMessage: 'Loading...',
  user: {
	name: ''
  },
  items: ['coffee', 'tea', 'soda']
});

const stale = () => {
  if (state.isLoading) {
    // this will become stale and will never used again
    // this is called 'stale dependencies'
    return state.loadingMessage;
  }
  
  return state.loadedMessage;
}

// we cannot detect array prop changes
// only using 'push' and 'splice'
state.items[0] = 'water';

// adding props won't work
// we need to have properties predefined.
state.user.age = 19;

Future of Reactivity in Vue.js

  • Will use ES6 proxies instead of ES5 getters/setters.

  • Will be able to handle arrays better.

  • Will be able to handle newly added properties.

  • Half the memory usage and double the speed.

2
 Advanced issues found
 

New composition API

  • No call order problem.
  • Automatic cleanup of watchers.
<template>
  <button @click="increment">
    Count is: {{ state.count }}, double is: {{ state.double }}
  </button>
</template>

<script>
import { reactive, computed } from 'vue'

export default {
  setup() {
    const state = reactive({
      count: 0,
      double: computed(() => state.count * 2)
    })

    function increment() {
      state.count++
    }

    return {
      state,
      increment
    }
  }
}
</script>

Links

Thank you

Vue.js Reactivity Explained

By Abdelrahman Awad

Vue.js Reactivity Explained

I teach how to build a buggy, slow, and horrible reactivity/rendering demo to appreciate Vue.js reactivity and the optimizations it does for us.

  • 2,837