Reactivity in depth

With this topic, you can take a deeper dive into the reactivity system.

What is reactivity?

Take a simple example. Suppose there are two variables a and b, and the third variable c is derived from a and b. The relation between them is as below:

copy
c = a + b

In traditional imperative programming, whenever a or b changes, the value assignment should be called manually to maintain the above equation. In frontend development, the factors that trigger a or b changes, such as user operation, timer, data request success, etc., are diversified. In this case, developers often forget to call the value assignment, thus causing the relation in the above equation to no longer be satisfied and bugs to occur.

Thanks to the reactivity system, we just need to declare the logic relation between c and a & b and do not need to worry about the data synchronization, so the burden on the developers is minimized and the bugs disappear.

How reactivity works in Goldfish

There are many JavaScript libraries in the community which have implemented the reactivity system, such as Vue and MobX. Goldfish is built with its own implementation, which is basically consistent with Vue in terms of principle and experience.

In principle, Goldfish implements reactivity by intercepting getters and setters. The following functions form the basic content of reactivity in Goldfish.

To convert standard data into reactive data, use the state() function:

copy
import { useState, setupPage } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
  });

  return {};
}));

The common JavaScript object becomes a reactive JavaScript object after being processed by the useState() function. Goldfish deeply traverses the target objects, converts all enumerable attributes into corresponding getters and setters, and prepares for the interception logic. When data.name = 'Jane' is executed, it triggers the prepared setter, then Goldfish senses the change and marks data.name = 'Jane' with changed. When data.name is visited, it triggers the prepared getter, then Goldfish knows its value is being visited, and the new value of data.name is returned.

You can also use useComputed()to generate the derived data:

copy
import { useState, setupPage, useComputed } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
  });

  const computed = useComputed({
    get fullName() {
      return `${data.name}.Ant`;
    },
  });

  return {};
}));

computed.fullName is the data derived from data.name. When data.name changes, computed.fullName changes too. So data.name can also be considered as the dependent data of computed.fullName.

When the data changes, you can use autorun() to execute some operations:

copy
import { useState, setupPage, useAutorun } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
  });

  const autorun = useAutorun();
  autorun(() => {
    console.log(data.name);
  });

  return {};
}));

When data.name changes, the callback function that is passed to autorun() will be executed once. data.name is considered as the dependent data of autorun().

You can also use watch() to listen for the change of the reactive data, and execute some operations upon the change:

copy
import { useState, setupPage, useWatch } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
  });

  const watch = useWatch();
  watch(
    () => data.name,
    (newVal) => {
      console.log(newVal);
    },
  );

  return {};
}));

When the result of the first callback function changes, the second callback function that is passed to watch() will be executed once. data.name that affects the result of the first callback function is considered as the dependent data of watch().

Now, you may ask yourself, why can computed, autorun(), andwatch()sense the change of the dependent data?

Actually, when the getter of computed, the callback function of autorun(), and the first callback function of watch() is executed, it accesses the getter of a series of reactive data in the function body. Therefore, Goldfish knows the dependencies. When such dependencies change, the corresponding logic is triggered.

Consider the following code example:

copy
import { useState, setupPage, useComputed } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
  year: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
    year: 5,
  });

  const computed = useComputed({
    get fullName() {
      if (data.age > 30) {
        return `${data.name}.${data.year}Old Ant`;
      }

      return `${data.name}.Young Ant`;
    },
  });

  return {};
}));

When first getting the value of computed.fullName, the getter function is executed. Then data.age is accessed, so data.age becomes a dependency of computed.fullName. And then, data.name is accessed, so data.age also becomes a dependency of computed.fullName. Now, the first value calculation and dependence recording are completed.

When data.age turns into 31, Goldfish internally marks computed.fullName as dirty. In the next accessing to computed.fullName, the getter function of computed.fullName is executed, and the discovered dependencies are data.age, data.name, and data.year in turn.

The dependence recording of autorun() and watch() is similar to computed.

Handle broken reactive link

When passing the reactive data, the reactive link may be frequently broken if you are not familiar with the reactivity principles.

Consider the following code example:

copy
import { useState, setupPage } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
  });

  return {
    name: data.name,
  };
}));

When the template data is returned, we assign the value of data.name to the name property of the returned object. Specifically, we just assign the snapshot value of data.name to the name property of the returned object. Therefore, subsequent changes to data.name cannot be reflected in the template data.

There are two ways of coding to resolve this issue as shown below:

copy
import { useState, setupPage } from '@goldfishjs/core';

interface IState {
  name: string;
  age: number;
}

Page(setupPage(() => {
  const data = useState<IState>({
    name: 'John',
    age: 29,
  });

  return {
    // Use data.name to access data values in AXML files.
    data,
    // Use name to access data values in AXML files.
    get name() {
      return data.name;
    },
  };
}));