loading...

React class functionality in function components

merri profile image Vesa Piittinen ・4 min read

Hooks are the thing everybody loves. But classes still have their uses.

So...

Why not have both?

Don't do this

So what am I talking about, really? Well, I had this idea on wrapping the good old React class components into something that combines most of the features from the hook world and classes. Make Classes Beautiful Again. Have class components with syntax that is similar to function components.

As an overall idea this probably shouldn't be done. But you know the internet. There is always somebody who does the thing which shouldn't be done.

What does it look like?

This is from my initial implementation and I should get rid of the use object, but here we go!

const Test = classHook((use, props) => {
    const [{ hello }, setState] = use.state(() => ({ hello: 'world' }))

    const onClick = use.callback(() => {
        setState({ hello: hello === 'world' ? 'nothing' : 'world' })
        alert('Hello ' + hello + '!')
    }, [hello])

    use.mount(() => {
        console.log('I mounted!')
        return () => console.log('I will unmount!')
    })

    use.update(() => {
        console.log('Before render')
        return () => console.log('After render')
    })

    use.updateIf(() => true)

    return <button onClick={onClick}>{JSON.stringify(props)}</button>
})

These may need some explanations... And we're really hacking the system here.

use.mount(props, state)

This is componentDidMount. You can use it once per component.

If you want to cleanup stuff on unmount you can return a function. That is componentWillUnmount. It will also receive props and state.

use.update(prevProps, prevState)

You can use this method to gather information from the DOM right before render. So yes, this is actually getSnapshotBeforeUpdate! You can use it only once per component.

Return a function here and you can update state after render based on the information you gather before render. This is equivalent to componentDidUpdate, but you will receive props and state (not prevProps and prevState, you already have them!)

use.updateIf(nextProps, nextState)

Return true to render. Return false to not render. This is shouldComponentUpdate. You can use it only once per component.

use.state

Similar to useState hook, but you can use it only once per component as you are working with the state of a class component.

Other supported hooks

use.callback, use.effect, use.memo, and use.ref each work as you would assume them to work.

How is this crime of Reactverse implemented?

As said this is the very first implementation just to have some fun. With a quick look on the interwebs I didn't find anyone who had done this before. I could find react-universal-hooks but it does kind of the opposite by allowing hooks inside existing class components, while I wanted to have class components but with hook-like syntax :)

function memoDiff(arr1, arr2) {
    if (arr1 == null) return false
    for (let i = 0; i < arr1.length; i++) {
        if (!Object.is(arr1[i], arr2[i])) return true
    }
    return false
}

function classHook(render) {
    if (typeof render !== 'function') {
        throw new Error('classHook needs function component')
    }

    class ClassHook extends React.PureComponent {
        constructor(props) {
            super(props)

            this.effects = []
            this.once = {}
            this.use = {}
            this.memoBusters = []
            this.memoIndex = 0
            this.menos = []
            this.refIndex = 0
            this.refs = []
            let setState

            Object.defineProperty(this.use, 'callback', {
                value: (fn, memo) => {
                    if (typeof fn !== 'function') return
                    if (this.memoIndex === this.menos.length) {
                        this.menos.push(fn)
                        this.memoBusters.push(memo)
                    } else if (memoDiff(this.memoBusters[this.memoIndex], memo)) {
                        this.menos[this.memoIndex] = fn
                        this.memoBusters[this.memoIndex] = memo
                    }
                    const memoFn = this.menos[this.memoIndex]
                    this.memoIndex++
                    return memoFn
                }
            })

            Object.defineProperty(this.use, 'effect', {
                value: (fn, memo) => {
                    if (typeof fn !== 'function') return
                    if (this.memoIndex === this.menos.length) {
                        this.menos.push(fn)
                        this.memoBusters.push(memo)
                        this.effects.push(fn)
                    } else if (memoDiff(this.memoBusters[this.memoIndex], memo)) {
                        this.menos[this.memoIndex] = fn
                        this.memoBusters[this.memoIndex] = memo
                        this.effects.push(fn)
                    }
                    this.memoIndex++
                }
            })

            Object.defineProperty(this.use, 'memo', {
                value: (fn, memo) => {
                    if (typeof fn !== 'function') return
                    if (this.memoIndex === this.menos.length) {
                        this.menos.push(fn())
                        this.memoBusters.push(memo)
                    } else if (memoDiff(this.memoBusters[this.memoIndex], memo)) {
                        this.menos[this.memoIndex] = fn()
                        this.memoBusters[this.memoIndex] = memo
                    }
                    const memoValue = this.menos[this.memoIndex]
                    this.memoIndex++
                    return memoValue
                }
            })

            Object.defineProperty(this.use, 'ref', {
                value: value => {
                    if (this.refIndex === this.refs.length) {
                        this.refs.push(React.createRef())
                        if (value != null) this.refs[this.refIndex] = value
                    }
                    const ref = this.refs[this.refIndex]
                    this.refIndex++
                    return ref
                }
            })

            Object.defineProperty(this.use, 'mount', {
                value: fn => {
                    if (!this._componentDidMount && typeof fn === 'function') {
                        this._componentDidMount = fn
                    }
                }
            })

            Object.defineProperty(this.use, 'update', {
                value: fn => {
                    if (!this.once.update && typeof fn === 'function') {
                        this._getSnapshotBeforeUpdate = fn
                        this.once.update = true
                    }
                }
            })

            Object.defineProperty(this.use, 'updateIf', {
                value: fn => {
                    if (!this.once.updateIf && typeof fn === 'function') {
                        this.shouldComponentUpdate = fn
                        this.once.updateIf = true
                    }
                }
            })

            Object.defineProperty(this.use, 'state', {
                value: fn => {
                    if (this.once.state) return
                    if (!setState) {
                        setState = this.setState.bind(this)
                        // eslint-disable-next-line
                        this.state = typeof fn === 'function' ? fn() : fn
                    }
                    this.once.state = true
                    return [this.state, setState]
                }
            })
        }

        componentDidMount() {
            if (this._componentDidMount) {
                const fn = this._componentDidMount(this.props, this.state)
                if (typeof fn === 'function') {
                    this._componentWillUnmount = fn
                }
            }
        }

        componentWillUnmount() {
            if (this._componentWillUnmount) {
                this._componentWillUnmount(this.props, this.state)
            }
        }

        getSnapshotBeforeUpdate(prevProps, prevState) {
            if (this._getSnapshotBeforeUpdate) {
                const fn = this._getSnapshotBeforeUpdate(prevProps, prevState)
                if (typeof fn === 'function') {
                    this._componentDidUpdate = fn
                }
            }
            return null
        }

        componentDidUpdate() {
            if (this.effects.length) {
                this.effects.forEach(effect => effect())
                this.effects.length = 0
            }
            if (this._componentDidUpdate) {
                this._componentDidUpdate(this.props, this.state)
            }
        }

        render() {
            this.once = {}
            this.memoIndex = this.refIndex = 0
            return render(this.use, this.props)
        }
    }

    return ClassHook
}

Certainly not stuff you would want to have anywhere close your production site :) I haven't done thorough testing on this either so "it seems to work" but probably breaks badly on some edge cases. But maybe someone finds this fun to play around with!

Can't I just have CodeSandbox or something?

Yes.

I'm sorry that I didn't bother to create the most awesome demo ever. This only has a couple hours worth of work on it and I'm not sure if I bother to really improve the idea :)

The two things that could be done:

  1. Use react-universal-hooks to provide support for hooks in classes. This way there is no need to re-implement existing React hooks.
  2. Abstract the class related custom hooks so that you can use them like useClassMount, useClassUpdate, useClassState and useUpdateClassIf or something like that.

Have fun!

Posted on by:

merri profile

Vesa Piittinen

@merri

User centric frontend specialist between "normal" programming and design. Loves perf and minimalism. Prefers HTML, CSS, Web Standards over JS, UX over DX. Hates div disease.

Discussion

markdown guide