@canlooks/reactive

A very simple and lightweight tool for responding data and managing state.

Usage no npm install needed!

<script type="module">
  import canlooksReactive from 'https://cdn.skypack.dev/@canlooks/reactive';
</script>

README

@canlooks/reactive

This was my private tool originally. Many of my workmates thought it was useful. Let me share.

This is a very simple and lightweight tool for responding data and managing state.
It's similar to mobx a little, but more simple and freewheeling.

Install

npm i @canlooks/reactive

A quick example

The code which using @canlooks/reactive looks like this.

import React from 'react'
import {Component, reactive} from '@canlooks/reactive'

@reactive
export default class Index extends Component {
    count = 1
    test = 2

    onClick() {
        this.count++ // This component will update.
    }

    myTest() {
        this.test++
        // This component will NOT update,
        // because "test" have never referred in "render()" 
    }

    render() {
        return (
            <div>
                <div>{this.count}</div>
                <button onClick={this.onClick}>Increase</button>
                <button onClick={this.myTest}>Test</button>
            </div>
        )
    }
}

Contents

Basic functions

External data & sharing state

props & $props

Optimization & additional functions

Notes


reactive()

There are 3 ways to create a reactive data.

import {reactive} from '@canlooks/reactive'

// create by object
const data = reactive({
    a: 1,
    b: 2
})

// create by class
const DataClass = reactive(class {
    a = 1
    static b = 2 // It doesn't work.
})
// Only instance properties are listened.
const dataInstance = new DataClass()

// using decorator
@reactive
class Data {
    a = 1
}

You can even create a reactive React Class Component like quick example, or functional component like this

const Index = reactive(() => {
    return (
        <div>Hello Reactive</div>
    )
})

@reactive.pure()

It's a reactive way to create React.PureComponent.

import {Component, reactive} from '@canlooks/reactive'

@reactive.pure
class Index extends Component {

}

useReactive()

To create an internal reactive state in functional component.

import React, {useCallback} from 'react'
import {reactive, useReactive} from '@canlooks/reactive'

const Index = reactive(() => {
    const data = useReactive({
        count: 1
    })

    const onClick = useCallback(() => {
        data.count++
        // This component will update.
        // Even in "useCallback", data is up to date.
    }, [])

    return (
        <div>
            <div>{data.count}</div>
            <button onClick={onClick}>increase</button>
        </div>
    )
})

External data & sharing state

You do not have to provide/inject, just use it.

import {Component, reactive} from '@canlooks/reactive'

const data = reactive({
    a: 1,
    b: 2
})

@reactive
class A extends Component {
    render() {
        return (
            <div>{data.a}</div>
        )
    }
}

const B = reactive(() => {
    return (
        <div>
            <div>{data.a}</div>
            <div>{data.b}</div>
        </div>
    )
})

// Change data everywhere.
data.a++ // Both component "A" and "B" will update.
data.b++ // Only component "B" will update.

props & $props

Because of React's limitation. props in Class Component is not proxiable, so there is a clone property named $props.

import {Component, reactive, watch} from '@canlooks/reactive'

@reactive
class Index extends Component<{
    a: any
    b: any
}> {
    @watch(t => t.props)
    effect1() {
        // Any of properties in "props" change,
        // this effect will execute regardless.
    }

    @watch(t => t.$props.a) // use "$props"
    effect2() {
        // this effect will execute when only prop "a" change.
    }
}

I recommend using $props instead props in every Class Components.
And extending Component in "@canlooks/reactive" instead "React", if you use typescript.
Specially, props in functional component is proxiable, so there is no $props in functional component.


<Fragment/>

<Fragment/> can take component to pleces for updating only a small part, not the entire component.

import {Fragment, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    count = 1
    test = 2

    onClick() {
        this.count++
        // Component "Index" and "ChildB" will not re-render,
        // only component "ChildA" will update.
    }

    render() {
        return (
            <div>
                <Fragment>
                    {() => <ChildA count={this.count}/>}
                </Fragment>
                <Fragment>
                    {() => <ChildB data={this.test}/>}
                </Fragment>
                <button onClick={this.onClick}>Increase</button>
            </div>
        )
    }
}

<Model/>

<Model/> can create two-way data bindings on form elements.

import {Model, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    inputValue = 'Hello Reactive' // This value always sync with value of <input/>

    render() {
        return (
            <div>
                <Model bindState={() => this.inputValue}>
                    {() => <input/>}
                </Model>
            </div>
        )
    }
}
props type description
bindState Function Reference function, In this function, referred property which made by reactive() will bind on child element.
children ReactElement
()=>ReactElement
I recommend using function type. Otherwise, children will NOT update except value changed.
onChange Function To define your own onChange callback.

@watch()

@watch() can respond to reactive data's change. If it is used in React Component, it will dispose automatically when componentWillUnmount.

import {Component, reactive, watch} from '@canlooks/reactive'

@reactive
class Index extends Component {
    @watch(() => someExternal.data)
    effect1() {
        // This effect will execute when "someExternal.data" change.
    }

    a = 1
    b = 2

    @watch<Index>(t => [t.a, t.b]) // t === this
    effect2() {
        // This effect will execute when "this.a" or "this.b" change.    
    }

    render() {
        return (
            <div>

            </div>
        )
    }
}

type Options = { immediate?: boolean, once?: boolean }

function Watch(refer: Refer, options: Options): MethodDecorator

Type of Refer description
<T>(this: T, context: T) => any This is a reference function.
In that function, every referred properties which made by reactive() will bind on effect.
You just need to refer them, return value is not a must.

@loading()

In some cases, many async functions take a long time to return. @loading() could be a good helper.

import {loading, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    busy = false

    @loading(t => t.busy) // t === this
    async myAsyncMethod() {
        // It change "busy" to true,
        // and change false back until this function return.
    }

    stack = 0

    @loading(t => t.stack)
    async concurrent() {
        // It make "stack" +1,
        // and -1 until this function return.
    }

    render() {
        return (
            <div>
                {(this.busy || this.stack !== 0) &&
                    <div>I'm busy</div>
                }
            </div>
        )
    }
}

As mentioned above, the parameter in @loading() is reference function.


@noOverlap()

import {noOverlap, reactive} from '@canlooks/reactive'

@reactive
class Data {
    @noOverlap
    async mayConcurrent() {
        // This function can not execute concurrently.
    }
}

const data = new Data()
data.mayConcurrent()
    .then(console.log)
data.mayConcurrent()   // This method may not execute,
    .then(console.log) // but you can still get the return value.

@cache()

@cache() extends @noOverlap(), the decorated function will not execute concurrently.
In addition, @cache() create a memoized function which execute only once if you don't force update.
It may be helpful for managing public data.

import {cache, reactive} from '@canlooks/reactive'

@reactive
class Data {
    @cache cachedMethod() {

    }
}

const data = new Data()
data.cachedMethod()
data.cachedMethod() // This method may not execute, but you can still get the return value.

There are several ways to force execute a cached function.

import {cache, CacheParams, reactive} from '@canlooks/reactive'

@reactive
class Data {
    @cache
    // Define parameter type to "CacheParams" if you use typescript.
    cachedMethod(params: CacheParams<{}>) {

    }

    @cache((t, params) => params.times === 2)
    myCachedMethod(params: {
        times: number
    }) {

    }
}

const data = new Data()
data.cachedMethod({forceUpdate: true}) // Default way.
data.myCachedMethod({times: 2})        // Custom way.
cache.update(data.cachedMethod)()      // High-order function

function Cache(refer: Refer): MethodDecorator

Type of Refer description
(this: T, context: T, ...args: any[]) => boolean This is a function whose return value determines whether cached needs to be updated.

nextTick()

import React, {createRef} from 'react'
import {Component, nextTick, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    showInput = false
    inputElement = createRef()

    async onClick() {
        this.showInput = true
        console.log(this.inputElement.current) // null
        await nextTick()
        console.log(this.inputElement.current) // <input/>
    }

    render() {
        return (
            <div>
                <button onClick={this.onClick}>button</button>
                {this.showInput &&
                    <input ref={this.inputElement}/>
                }
            </div>
        )
    }
}

function nextTick<T>(callback?: (...args: T[]) => void, ...args: T[]): Promise<T>


reactor()

reactor() is similar to @watch(), but you should dispose it by yourself.

import {reactive, reactor} from '@canlooks/reactive'

const obj = reactive({
    a: 1,
    b: 2
})

const disposer = reactor(() => obj.a, () => {
    console.log('"obj.a" was changed to ' + obj.a)
})

obj.a++ // log: "obj.a" was changed to 2
obj.b++ // nothing happen

disposer() // To dispose if you don't use it anymore.

function reactor(refer: Refer, effect: Function): Disposer

As mentioned above, the first parameter is reference function.


autorun()

autorun() is a syntactic sugar of reactor().

import {reactive, autorun} from '@canlooks/reactive'

const obj = reactive({
    a: 1
})

const disposer = autorun(() => {
    console.log('Now "obj.a" is: ' + obj.a)
})

Notes

Shallow proxy

reactive()method create a shallow-proxy object, it means only top-level properties are responsive. For example

import {Component, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    obj = {
        a: 1
    }

    method() {
        this.obj.a++             // This component doesn't update yet.
        this.obj = {...this.obj} // It works.
    }

    render() {
        // ...
    }
}

You can "reactive" deeper if you need it. For example

import {Component, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    obj = reactive({
        a: 1
    })

    method() {
        this.obj.a++ // It works
    }

    render() {
        // ...
    }
}

Auto-cache getter

"getter" property will auto-cache.

import {reactive} from '@canlooks/reactive'

@reactive
class Data {
    count = 1

    get double() {
        this.count * 2
        // This function will not execute repeatedly until "count" changes.
    }
}

AutoBind

import {Component, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    onClick() {
        console.log(this) // Always get the correct "this".
    }

    render() {
        return (
            <button onClick={this.onClick}>button</button>
        )
    }
}

Automatic batching

Before React@18.0.0, batching isn't automatic. Setting states in async-function will probably make component update many times unnecessarily.
But in @canlooks/reactive, components updating are batched.

import {Component, nextTick, reactive} from '@canlooks/reactive'

@reactive
class Index extends Component {
    a = 1
    b = 2

    method1() {
        setTimeout(() => {
            this.a++
            this.b++
            // This component update only once.
        }, 1000)
    }
    
    async method2() {
        this.a++
        await nextTick()
        this.b++
        // This component will update twice.
    }

    render() {
        // ...
    }
}

The reference function

Like @watch() <Model/> @loading() reactor(), they all have a similar parameter called reference-function. It only works in sync-function. For example

import {reactive, watch} from '@canlooks/reactive'

@reactive
class Data {
    @watch(() => {
        setTimeout(() => {
            someExternal.data // It doesn't work.
        }, 0)
    })
    method1() {

    }

    @watch(async () => someExternal.data)  // It doesn't work.
    method2() {

    }

    @watch(() => someExternal.data)  // It works.
    method3() {

    }
}