Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

getState() hook proposal #14092

Closed
denysonique opened this issue Nov 4, 2018 · 16 comments
Closed

getState() hook proposal #14092

denysonique opened this issue Nov 4, 2018 · 16 comments

Comments

@denysonique
Copy link

denysonique commented Nov 4, 2018

useState() provided state value currently cannot be used in useEffect(fn, []) - (componentDidMount-like scenario) with asynchronous functions after state has been updated following the initial [] run.

Upon trying to give Hooks a try for a real world application I was initially confused with accessing state. Here is a little example what I tried to do:

const UserList = () => {
    const [users, setUsers] = useState([])
    useEffect(() => {
        const socket = io('/dashboard')
        socket.on('user:connect', (user) => {
            setUsers([...users, user])
        })
        socket.on('user:update', (user) => {
            let newUsers = users.map((u) => u.id == user.id ? user : u)
            setUsers(newUsers)
        }) 
    }, [])

    return (
        users.map(({id, email}) => (
            <tr key={id}>
                <td>{id}</td>
                <td>{email}</td>
            </tr>
        ))
    )
}

Upon running this I instantly realised that inside the socket.on() handler the users initially obtained from useState() did not reflect the changes inflicted by setUsers() ran on socket.on('user:connect'). Passing [users] as the second argument of useEffect() wasn't an option as that would cause additional socket.on() binds. I became skeptical about Hooks for this use case and sadly thought this would be where my journey with using hooks instead of the class components would end.

Fortunately I then found a solution to this problem (with someone indirectly having helped me by accident in the reactflux channel) by using an updater function with setState() which made it all work:

  socket.on('user:update', (user) => {
            setUsers(users => users.map((u) => u.id == user.id ? user : u))
   })

The setState() problem was solved, but I am now wondering that if I will ever need to access state outside of an updater function, i.e. to just reply to a WebSocket message with some value from the state I will be unable to do so and this will force me and other users to revert to class components for such cases.

I therefore would like to suggest that a getState() hook would be an ideal solution to this problem.
                                                                                                                      
 
 
 
Here is another mini example demonstrating the problem in a more concise manner:

const HooksComponent = () => {
    const [value, setValue] = useState({ val: 0 });

    useEffect(() => {
        setTimeout(() => setValue({ val: 10 }), 100)
        setTimeout(() => console.log('value: ', value.val), 200)
    }, []);
}
//console.log output: 0 instead of 10

And here is one with a proposed solution:

const HooksComponent = () => {
    const [state, setState, getState] = useState({ val: 0 });

    useEffect(() => {
        setTimeout(() => setState({ val: 10 }), 100)
        setTimeout(() => {
            getState(state => {
                console.log('value: ', state.val)
            })
        }, 200)
    }, [])
@denysonique denysonique changed the title getState hook getState() hook proposal Nov 4, 2018
@ChibiBlasphem
Copy link

ChibiBlasphem commented Nov 4, 2018

I don't think this is necessary, you need to clean up your socket.on calls by the way.

With this you can pass the state as the second argument of useEffect and each time the your state changes you socket listeners will be clean up and resubscribe.

Btw I think this is wanted that you cannot access new state in useEffect. It's avoiding misuse of it.

@denysonique
Copy link
Author

denysonique commented Nov 4, 2018

With this you can pass the state as the second argument of useEffect and each time the your state changes you socket listeners will be clean up and resubscribe.

Unsubscribing and subscribing sockets on every message would be a very idea, performance wise the least

@thchia
Copy link

thchia commented Nov 5, 2018

This is intended, though I think there is a way around this by using useReducer instead of useState (see more here).

@jfrolich
Copy link

jfrolich commented Nov 5, 2018

With this you can pass the state as the second argument of useEffect and each time the your state changes you socket listeners will be clean up and resubscribe.

Unsubscribing and subscribing sockets on every message would be a very idea, performance wise the least

Probably better to split it up in two effects! (connect as a separate effect)

@ChibiBlasphem
Copy link

ChibiBlasphem commented Nov 5, 2018

Yup the idea of @thchia to use useReducer is brilliant, you could dispatch and action with the new user, which internally would be merge with the current user list.

Like this:

const usersReducer = (users = [], actions = {}) => {
  switch (action.type) {
    case 'ADD_USER': return [...users, action.payload.user]
    case 'UPDATE_USER': {
      const { user } = action.payload
      return users.map((u) => u.id == user.id ? user : u)
    }
    default: return users
  }
}

const UserList = () => {
    const [users, dispatch] = useReducer([])
    useEffect(() => {
        const socket = io('/dashboard')
        socket.on('user:connect', (user) => {
            dispatch({ type: 'ADD_USER', payload: { user } })
        })
        socket.on('user:update', (user) => {
            dispatch({ type: 'UPDATE_USER', payload: { user } })
        }) 
    }, [])

    return (
        users.map(({id, email}) => (
            <tr key={id}>
                <td>{id}</td>
                <td>{email}</td>
            </tr>
        ))
    )
}

EDIT: An example with custom event (instead of sockets connections)
https://codesandbox.io/s/8n6kvqqj3j

@denysonique
Copy link
Author

denysonique commented Nov 5, 2018

I have considered useReducer before, but I do not wan't to use 'redux' or any other boilerplate in this instance. I want simplicity and in this case it turns out that it can be better achieved with class components.

@ChibiBlasphem
Copy link

reducer does not mean "Redux", it's just a "reducer" pretty much like "Array.prototype.reduce"
You can try to use "useRef" which could possible let you access the current value at any time.

@denysonique
Copy link
Author

@ChibiBlasphem this again means boilerplate or jumping through hoops, I expect React Hooks to make my life simpler not more complicated.

@ChibiBlasphem
Copy link

ChibiBlasphem commented Nov 5, 2018

Hum I don't think hooks mean to make life "simplier", your code is better designed with less possibility to break

Nothing stops you from doing this
When calling the "setUsers" returned by the custom hook, the ref is updated too.

function useUserList(initialUsers = []) {
  const [users, setUsers] = useState(initialUsers)
  const usersRef = useRef(users)

  return {
    users: usersRef,
    setUsers: function(mapper) {
      usersRef.current = mapper(usersRef.current)
      setUsers(mapper)
    },
  }
}

const UserList = () => {
  const { users, setUsers } = useUserList()
  // ...
}

Refs are made for this:
https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables

@gaearon
Copy link
Collaborator

gaearon commented Nov 5, 2018

This is a known limitation. We might make useCallback handle these cases better in the future. We know that in practice it invalidates too often. The current recommended workaround is one of three options:

  1. Re-subscribe if it's cheap enough.
  2. If re-subscribing is expensive, you could wrap the API you're using to make it cheap.
  3. You can useReducer or setState(updaterFn) to avoid closing over the variables. This doesn't solve all use cases (especially when you also need to perform side effects) but gets you pretty close.

There is also a more hacky workaround using refs that essentially emulates what classes do. I only recommend it as last resort since it may cause some issues in the future, but at least it's no worse than classes.

We want to provide a better solution but it will take some time.

@gaearon
Copy link
Collaborator

gaearon commented Nov 5, 2018

I filed #14099 to track the root problem.

@pzhine
Copy link

pzhine commented Aug 15, 2019

You can useReducer or setState(updaterFn) to avoid closing over the variables. This doesn't solve all use cases (especially when you also need to perform side effects) but gets you pretty close.

@gaearon, it would be really useful to add the updaterFn argument to the dispatch method returned by useReducer, so you could do something like:

const [, dispatch] = useReducer(reducer, initialState)
dispatch(state => state.isLoading || doSomething())

In that case, state would always point to the freshest state.

@furnaceX
Copy link

furnaceX commented May 23, 2020

How about a 3rd parameter on a useEffect hook, where the 3rd parameter lists variables that should pass in updated values, but not trigger execution of the useEffect. Kind of a hybrid of a useCallback and useEffect.

So rewriting the original proposal's example:

const HooksComponent = () => {
  const [state, setState] = useState({ val: 0 });

  useEffect(() => {
    setTimeout(() => setState({ val: 10 }), 100)
    setTimeout(() => {
      console.log('value: ', state.val)
    }, 200)
  }, [], [state])
}

@ChibiBlasphem
Copy link

@furnaceX it can't work. You can't update the variable state inside the callback without reexecuting the function... That's how closure are working. It keeps the value at the moment the function were declared.

@gioragutt
Copy link

gioragutt commented Sep 29, 2021

Using the callback-based setState solves your problem.

const UserList = () => {
    const [users, setUsers] = useState([])

    useEffect(() => {
        const socket = io('/dashboard')
        socket.on('user:connect', (user) => {
            setUsers(currentUsers => [...currentUsers, user]);
        })
        socket.on('user:update', (user) => {
            setUsers(currentUsers => currentUsers.map((u) => u.id == user.id ? user : u));
        }) 
    }, [])

    return (
        users.map(({id, email}) => (
            <tr key={id}>
                <td>{id}</td>
                <td>{email}</td>
            </tr>
        ))
    )
}

@gaearon
Copy link
Collaborator

gaearon commented May 4, 2022

We've submitted an alternative proposal to solve this. Would appreciate your input!

reactjs/rfcs#220

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants