jhonnymichel / react-hookstore

A state management library for react using the bleeding edge hooks feature

Home Page:https://codesandbox.io/s/r58pqonkop

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Store doesn't update on component will mount.

Jiri-Mihal opened this issue · comments

import * as React from "react";
import {useState} from "react";
import {createStore, useStore} from "react-hookstore";

createStore('clickStore', {clicks: 0});

function Component(props) {
    const [willMount, setWillMount] = useState(true);
    const [clicks, setClicks] = useStore('clickStore');

    if (willMount) {
        console.log('First load.');
        console.log('Constructor / componentWillMount hook simulation.');
        console.log('This loads only once before rendering the component.');
        setClicks({clicks: 1});
        setWillMount(false);
    } else {
        console.log('Any other load.');
        console.log(clicks.clicks); // !!!!! BUG -  Returns 0, should return 1.
    }

    return (<h1>Meow world!</h1>);
}

Yes. that is true. this happens because the subscription system kicks in at a useEffect hook. the component must be mounted before calling set states.

with that said, it's a bug because we could be taking care of this case.

in the meanwhile, simply move the setClicks code to a componentDidMount

@jhonnymichel I'm afraid this will not simulate constructor / componentWillMount. I think componentDidMount runs after rendering.

@jhonnymichel If you don't want to test the component lifecycle by your self and save some time, you can see my Stack answer here.

@Jiri-Mihal But why do you need the component to run that code before mount?

I understand it is not the same behaviour, but maybe you can adapt for now. at lease useEffect is prior to the render so I think you're fine, just adapt your implementation a little

In any case, I want to fix this issue, but it might take a few days for me to have time to do it. if you'd like to, feel free to open a PR!

@jhonnymichel React documentation says "The function passed to useEffect will run after the render is committed to the screen.", you can see this behavior in my Stack answer (the link as above).

I believe there are many cases when it's necessary to simulate constructor/componentWillMount behavior in functional components. For example, the 'use-global-hook' package works just fine in this scenario.

If I had time, or if there wasn't any suitable solution, I would make PR for you. Now you have a star and a useful bug report from me. I maintain my open source project and I would be happy for it. Keep making what you like.

sorry @Jiri-Mihal my useEffect descriptions was flawed. Well I got you. I will prioritise this to make sure you and others can simulate constructor calls

Hey @Jiri-Mihal. I tried an implementation, and it worked, but it's clearly imperfect. Would you discuss it with me? let's go:

I have to queue setState calls that happened prior to the first mount, and run then after first mount. Which means you can see a glimpse of the wrong initial state before the component really updates.

So I went to check use-global-hook to see how they did it, and it is the same implementation. Here is what I did with their basic example to test it:

import React from 'react';
import globalHook from 'use-global-hook';

const initialState = {
  counter: 0,
};

const actions = {
  addToCounter: (store, amount) => {
    const newCounterValue = store.state.counter + amount;
    store.setState({ counter: newCounterValue });
  },
};

const useGlobal = globalHook(React, initialState, actions);

const App = () => {
  const [globalState, globalActions] = useGlobal();

  const didMount = React.useRef(false);

  if (!didMount.current) {
    didMount.current = true;
    globalActions.addToCounter(1);
  }

  return (
    <div>
      <p>
        counter:
        {globalState.counter}
      </p>
      <button type="button" onClick={() => globalActions.addToCounter(1)}>
        +1 to global
      </button>
    </div>
  );
};

export default App;

the result is:, on the screen, I can see for a fraction of a second, the "0" before it turns into "1".

This is literally the same as you using useEffect inside your component, as I first suggested. I understand it is more convenient to someone using the library to not have to care about this, and let the library perform the magic. But don't you think the behaviour is misleading the users?

you expect that your first state is 1. but it really is 0. this can leads to all sorts of errors since you're counting on having a different value at first render.

I could make this behaviour clear in the docs, but IMO, It's better if this case is explicitly controlled by whoever is using the library.

With all that said, what do you think? what is your view? I understand your limitation and your reasons to need to call store.setState on 'constructor', but knowing that the only possible implementation (without breaking other stuff such as server side rendering, concurrent mode) is very smelly, I honestly feel like not "fixing" it (because I'm not really fixing anything IMO).

But this is an open source project and although I created it and maintain it, I don't want to dictate stuff. So I would like to see your view after all I'm saying here.

An argument against what I just said: not even React guarantees synchronous updates, why should I? following this line of thought I should implement the change.

use-global-hook is not ideal, fortunately it works in my case. It's because I don't change only the global state in the "willMount" condition, but I also change the internal state of the component. It prevents React to render the component with the initial value.

In your example, you have to set React's state in !didMount.current condition to prevent React to render the component with the default value of initialState. See the calling of setCount(2) in my Stack example.

So how to implement it in react-hookstore? I think function for changing state of components aka globalActions.addToCounter(1) should change the state of components immediately and not in componentDidMount hook link to your code. I didn't study all of your code, so maybe it can have some unwanted side effects.

Unfortunately I can't really do this, because of this #22

but here is what I'll do: I will do the same implementation that use-global-hook does and keep thinking of a better approach later.

@Jiri-Mihal done. 1.4.4 released! Thanks for your time in this discussion and for reporting the issue originally.

I still was not super happy with solution presented in 1.4.4 release, what about this one? No memory leaks and it behaves as same as React useState hook.

import { useState } from 'react';

let stores = {};
let subscriptions = {};

const defaultReducer = (state, payload) => payload;

/** The public interface of a store */
class StoreInterface {
    constructor(name, store, useReducer) {
        this.name = name;
        useReducer ? this.dispatch = store.setState : this.setState = store.setState;
        this.getState = () => store.state;
        this.subscribe = this.subscribe.bind(this);
    }

    /**
     * Subscribe to store changes
     * @callback callback - The function to be invoked everytime the store is updated
     * @return {Function} - Call the function returned by the method to cancel the subscription
     */

    /**
     *
     * @param {callback} state, action
     */
    subscribe(callback) {
        if (!callback || typeof callback !== 'function') {
            throw `store.subscribe callback argument must be a function. got '${typeof callback}' instead.`;
        }
        if (subscriptions[this.name].find(c => c === callback)) {
            console.warn('This callback is already subscribed to this store. skipping subscription');
            return;
        }
        subscriptions[this.name].push(callback);
        return () => {
            subscriptions[this.name] = subscriptions[this.name].filter(c => c !== callback);
        }
    }

    setState() {
        console.warn(`[React Hookstore] Store ${this.name} uses a reducer to handle its state updates. use dispatch instead of setState`)
    }

    dispatch() {
        console.warn(`[React Hookstore] Store ${this.name} does not use a reducer to handle state updates. use setState instead of dispatch`)
    }
}

function getStoreByIdentifier(identifier) {
    const name = identifier instanceof StoreInterface ? identifier.name : identifier;
    if (!stores[name]) {
        throw `Store with name ${name} does not exist`;
    }
    return stores[name];
}

/**
 * Creates a new store
 * @param {String} name - The store namespace.
 * @param {*} state [{}] - The store initial state. It can be of any type.
 * @callback reducer [null]
 * @returns {StoreInterface} The store instance.
 */

/**
 *
 * @param {reducer} prevState, action - The reducer handler. Optional.
 */
export function createStore(name, state = {}, reducer=defaultReducer) {
    if (typeof name !== 'string') {
        throw 'Store name must be a string';
    }
    if (stores[name]) {
        throw `Store with name ${name} already exists`;
    }

    const store = {
        state,
        reducer,
        setState(action, callback) {
            this.state = this.reducer(this.state, action);
            this.setters.forEach(setter => setter(this.state));
            if (subscriptions[name].length) {
                subscriptions[name].forEach(c => c(this.state, action));
            }
            if (typeof callback === 'function') callback(this.state);
        },
        setters: []
    };
    store.setState = store.setState.bind(store);
    subscriptions[name] = [];
    store.public = new StoreInterface(name, store, reducer !== defaultReducer);

    stores = Object.assign({}, stores, { [name]: store });
    return store.public;
}

/**
 * Returns a store instance based on its name
 * @callback {String} name - The name of the wanted store
 * @returns {StoreInterface} the store instance
 */
export function getStoreByName(name) {
    try {
        return stores[name].public;
    } catch(e) {
        throw `Store with name ${name} does not exist`;
    }
}

/**
 * Returns a [ state, setState ] pair for the selected store. Can only be called within React Components
 * @param {String|StoreInterface} identifier - The identifier for the wanted store
 * @returns {Array} the [state, setState] pair.
 */
export function useStore(identifier) {
    let store = getStoreByIdentifier(identifier);
    const [ state, set ] = useState(store.state);

    if (!store.setters.includes(set)) {
        store.setters.push(set);
    }

    return [ state, store.setState ];
}

Usage Example:

import { createStore, useStore } from 'react-hookstore';
import * as React from 'react';
import * as ReactDOM from "react-dom";

createStore('clickStore', 0);

const App = () => {
    const [timesClicked, setClicks] = useStore('clickStore');
    const didMount = React.useRef(false);

    if (!didMount.current) {
        didMount.current = true;
        setClicks(timesClicked + 1);
    }

    return (
        <div>
            <p>
                counter:
                {timesClicked}
            </p>
            <button type="button" onClick={() => setClicks(timesClicked + 1)}>
                +1 to global
            </button>
        </div>
    );
};

ReactDOM.render(<App/>, document.getElementById('app'));

Hey @Jiri-Mihal , sorry for taking so long to reply, I'm juggling between multiple contracts right now.

I know a good solution to avoid memory leaks while still pushing the subscription at component's mount. I just need to change your suggestion a little bit. You'll have the desired behavior. Hopefully I'll be able to release a new version tomorrow.

Hi @jhonnymichel, why do you want to keep the subscription at component did mount? React's useState() doesn't work like that. Wouldn't it be better to stay consistent with React's useState() behavior?

Hey @Jiri-Mihal, the thing is: while React can easily know or at least properly handle update calls on unmounted components, I can't, I'm outside of React. so in SSR where components don't really mount but execute their constructor, update calls on unmounted components would happen.

Basically, If I add the subscription on useEffect and remove on the useEffect returned function, I have a perfect sync between subscribe and unsubscribe.

but with your issue I understand I should also be closer to the useEffect implementation, so what I'll do is move the subscription back to the mount, the same as useEffect does, and add some clever try/catch to my update calls.

In case anyone is still there: v1.5.0 solves this issue.