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

Revisiting Hooks implementation #32

Open
krizzu opened this issue Mar 5, 2019 · 24 comments
Open

Revisiting Hooks implementation #32

krizzu opened this issue Mar 5, 2019 · 24 comments
Labels
enhancement New feature or request help wanted :octocat: question General question about usage

Comments

@krizzu
Copy link
Member

krizzu commented Mar 5, 2019

You want to:

Discuss required changes to current 'hook-ish' implementation for Hooks support.

Details:

As you can see here, we've got a nice wrapper on top AsyncStorage, to mimic hooks usage.
This discussion here is to come up with better and valid implementation, that leverages hooks API.

Example implementation could look like this:

function useAsyncStorage(key, defaultValue) {
  const [storageValue, updateStorageValue] = useState(defaultValue);

  useEffect(() => {
    getStorageValue();
  }, []);

  async function getStorageValue() {
    let value = defaultValue;
    try {
      value = (await AsyncStorage.getItem(key)) || defaultValue;
    } catch (e) {
      // handle here
    } finally {
      updateStorageValue(value);
    }
  }

  async function updateStorage(newValue) {
    try {
      await AsyncStorage.setItem(key, newValue);
    } catch (e) {
      // handle here
    } finally {
      getStorageValue();
    }
  }

  return [storageValue, updateStorage];
}

The problem in here is that, once we got a value inside AsyncStorage, we'll be getting two rerenders one component mounting - one returning the default value, second the actual stored data.

I'm more than happy to discuss this, so please let me know what you think.

@krizzu krizzu added enhancement New feature or request question General question about usage labels Mar 5, 2019
@rommyarb
Copy link
Contributor

rommyarb commented Mar 6, 2019

For now, I do it like this:

import React, { useState, useEffect } from "react";
import { View, TextInput } from "react-native";
import AsyncStorage from "@react-native-community/async-storage";

export default function MyApp(props) {
  const name = inputPersist("@name");
  
  return (
    <View>
      <TextInput {...name}/>
    <View>
  );
}

function inputPersist(key) {
  const [value, setValue] = useState();

  function readItem() {
    AsyncStorage.getItem(key).then(itemValue => setValue(itemValue));
  }

  useEffect(readItem, []);

  function handleChangeText(input) {
    AsyncStorage.setItem(key, input);
    setValue(input);
  }

  return {
    value,
    onChangeText: handleChangeText
  };
}

The value of TextInput is now persist.

@edwinvrgs
Copy link

edwinvrgs commented Mar 21, 2019

Maybe using a value to keep track of the status (updated or not) of the value would help?

  useEffect(() => {
    getStorageValue();
  }, [updated]);

@edwinvrgs
Copy link

edwinvrgs commented Mar 21, 2019

const useAsyncStorage = (key, defaultValue) => {
  const [storageValue, updateStorageValue] = useState(defaultValue);
  const [updated, setUpdated] = useState(false);

  async function getStorageValue() {
    let value = defaultValue;
    try {
      value = JSON.parse(await AsyncStorage.getItem(key)) || defaultValue;
    } catch (e) {
    } finally {
      updateStorageValue(value);
      setUpdated(true);
    }
  }

  async function updateStorage(newValue) {
    try {
      if (newValue === null) {
        await AsyncStorage.removeItem(key);
      } else {
        const value = JSON.stringify(newValue);
        await AsyncStorage.setItem(key, value);
      }
    } catch (e) {
    } finally {
      setUpdated(false);
      getStorageValue();
    }
  }

  useEffect(() => {
    getStorageValue();
  }, [updated]);

  return [storageValue, updateStorage];
};

@e6a5
Copy link

e6a5 commented Apr 18, 2019

const useAsyncStorage = (key, defaultValue) => {
  const [storageValue, updateStorageValue] = useState(defaultValue);
  const [updated, setUpdated] = useState(false);

  async function getStorageValue() {
    let value = defaultValue;
    try {
      value = JSON.parse(await AsyncStorage.getItem(key)) || defaultValue;
    } catch (e) {
    } finally {
      updateStorageValue(value);
      setUpdated(true);
    }
  }

  async function updateStorage(newValue) {
    try {
      if (newValue === null) {
        await AsyncStorage.removeItem(key);
      } else {
        const value = JSON.stringify(newValue);
        await AsyncStorage.setItem(key, value);
      }
    } catch (e) {
    } finally {
      setUpdated(false);
      getStorageValue();
    }
  }

  useEffect(() => {
    getStorageValue();
  }, [updated]);

  return [storageValue, updateStorage];
};

It still returns the default value in the first time. Can we get the actual stored data in the first time?

@krizzu
Copy link
Member Author

krizzu commented Jul 4, 2019

We're going to revisit it in v2.

@krizzu krizzu closed this as completed Jul 4, 2019
@dkniffin
Copy link

dkniffin commented Jul 18, 2019

I iterated on the previous version @edwinvrgs proposed and came up with this (in Typescript):

import AsyncStorage from '@react-native-community/async-storage'
import { useEffect, useState } from 'react'

const useAsyncStorage = <T>(key: string, defaultValue: T): [T, (newValue: T) => void, boolean] => {
  const [state, setState] = useState({
    hydrated: false,
    storageValue: defaultValue
  })
  const { hydrated, storageValue } = state

  async function pullFromStorage() {
    const fromStorage = await AsyncStorage.getItem(key)
    let value = defaultValue
    if (fromStorage) {
      value = JSON.parse(fromStorage)
    }
    setState({ hydrated: true, storageValue: value });
  }

  async function updateStorage(newValue: T) {
    setState({ hydrated: true, storageValue: newValue })
    const stringifiedValue = JSON.stringify(newValue);
    await AsyncStorage.setItem(key, stringifiedValue);
  }

  useEffect(() => {
    pullFromStorage();
  }, []);

  return [storageValue, updateStorage, hydrated];
};

export default useAsyncStorage

A couple notable tweaks:

  • Rename some things to use the terminology "hydrate" instead of "update"
  • Update the entire state object at once (rather than two separate steps), to reduce the number of renders
  • return hydrate so it can be used conditionally. This is a sort of workaround for the issue @tranhiepqna mentioned. AsyncStorage is (by definition) asynchronous, so I don't think there's any way we're going to be able to return the value on the first time through here. Instead, the idea is return the state that our hook is in, so people can do something like if (hydrated) { doTheThing() }

Note: I took out the error-handling for now, because I don't need it for my use-case

@edwinvrgs
Copy link

Man @dkniffin, great approach. I came up with a similar solution in my current project but nothing as polish as this proposal of you. The only thing that I can say is that maybe we can use useReducer now that we have 2 values in the state, and with that maybe we can include an error value to the state.

@dkniffin
Copy link

@edwinvrgs Yep, agreed. I think that makes sense. I've been working with this a bit more and tweaking it as a I go. The two notable changes I've made so far are:

  1. rename hydrated to synced
  2. Change updateStorage's setState to synced: false instead of true. That way, we can return whether the variable we've returned matches what's in storage. I could see a use-case where hydrated is also useful, just to know whether we've fetched from storage yet at the beginning, but I think synced is more useful, at least for what I'm doing.

@dkniffin
Copy link

My team has been using this solution I wrote on an app we're working on. We've been using it for a while now, and there's one gotcha we've just run into: updateStorage will immediately write into localStorage. In our case, we hooked up an input box to updateStorage, and the result was that every time someone typed into the box, it would write to storage (ie disk write), which caused some pretty bad lag behavior.

We fixed that by having the form state stored separately in memory and only write to it at specific times (when the user clicks a "Next" button). I could see that solution being incorporated into the snippet I wrote above (which ideally I'm hoping gets integrated into this library at some point), so that this useAsyncStorage keeps an in-memory state, which can be updated with a setState-like function, then only syncs to localStorage when updateState is called. Or, that functionality could be designated as "outside the scope of this solution", with a warning in the docs.

On this topic, @krizzu is this hook revisit still in the works for v2? When should we expect that?

@krizzu
Copy link
Member Author

krizzu commented Apr 30, 2020

Reopening for further discussions to improve hooks for v1.

@dkniffin I know it's been a while, but your solution looks pretty solid. What about *Many or removing functionality? I guess those methods could have their own hooks like useRemove / useAsyncStorageMany ? WDYT?

@krizzu krizzu reopened this Apr 30, 2020
@dkniffin
Copy link

@krizzu I'm not sure what you mean by What about *Many or removing functionality?. Looks like you might have made a typo?

As for this solution, you are welcome to use it. However, I am no longer working on the project that I implemented that in, so I can no longer attest to whether it's working or not, unfortunately, and I'm not actually working on any RN projects at the moment. Sorry.

@pke
Copy link

pke commented May 18, 2020

Can't useXXX functions be async in the first place?

[value, setValue] = await useAsyncStorage("@key") maybe together with suspend?

On the other hand, maybe such values should be loaded outside the component that needs them in the first place? Then the async nature of the refresh when the value is actually retrieved is not that obvious?

@mrousavy
Copy link

@pke No, since the render() function cannot be async. You have to have a default value (or undefined if you can't provide one), which is a placeholder until the actual value from the AsyncStorage is read.

This means, that there will always be at least one unavoidable re-render.

@mrousavy
Copy link

@dkniffin @krizzu to not always write to disk everytime the state changes, we could also use some sort of debouncing/throttling. I've wrote an example useDebouncedEffect hook here, which can be used to only write to disk (save to async storage) after xms of no state updates.

@abumalick
Copy link

Thanks @dkniffin

Here is about the same implementation using useCallback

import AsyncStorage from '@react-native-async-storage/async-storage'
import * as React from 'react'
import log from '../utils/log'

const useStoredState = <T>(
  key: string,
  defaultValue: T,
): [T, (newValue: T) => void, boolean] => {
  const [state, setState] = React.useState({
    hydrated: false,
    storageValue: defaultValue,
  })
  const {hydrated, storageValue} = state

  React.useEffect(() => {
    const pullFromStorage = async () => {
      let value = defaultValue
      try {
        const fromStorage = await AsyncStorage.getItem(key)
        if (fromStorage) {
          value = JSON.parse(fromStorage)
        }
      } catch (e) {
        log('Could not read from storage for key: ', key, e)
      }

      return value
    }
    pullFromStorage().then((value) => {
      setState({hydrated: true, storageValue: value})
    })

    // We don't want to update when the defaultValue changes
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [key])

  const updateStorage = React.useCallback(
    async (newValue: T) => {
      setState({hydrated: true, storageValue: newValue})
      const stringifiedValue = JSON.stringify(newValue)
      await AsyncStorage.setItem(key, stringifiedValue)
    },
    [key],
  )

  return [storageValue, updateStorage, hydrated]
}

export default useStoredState

@github-actions
Copy link

This issue has been marked as stale due to inactivity. Please respond or otherwise resolve the issue within 7 days or it will be closed.

@github-actions github-actions bot added the Stale label May 18, 2021
@tido64 tido64 added help wanted :octocat: and removed Stale labels May 18, 2021
@seeden
Copy link

seeden commented Dec 21, 2021

Here is my version with error state.

import AsyncStorage from '@react-native-async-storage/async-storage';
import { useEffect, useState, useCallback } from 'react';

export default function useAsyncStorage<T>(key: string, defaultValue: T): {
  value?: T,
  setValue: (newValue: T) => Promise<void>,
  removeValue: () => Promise<void>,
  loading?: boolean,
  error?: Error,
} {
  const [error, setError] = useState<Error | undefined>();
  const [loading, setLoading] = useState<boolean>(true);
  const [value, setValue] = useState<T | undefined>();

  async function getValueFromAsyncStorage(): Promise<void> {
    try {
      setLoading(true);
      setError(undefined);
      setValue(defaultValue);
      const value = await AsyncStorage.getItem(key);
      if (value !== null) {
        setValue(JSON.parse(value));
      }
    } catch (error: any) {
      setError(error);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    getValueFromAsyncStorage();
  }, [key])

  const handleSetValue = useCallback(async (newValue: T) => {
    const stringifiedValue = JSON.stringify(newValue);
    await AsyncStorage.setItem(key, stringifiedValue);

    setError(undefined);
    setValue(newValue);
    setLoading(false);
  }, [key]);

  const handleRemoveValue = useCallback(async () => {
    await AsyncStorage.removeItem(key);

    setError(undefined);
    setValue(undefined);
    setLoading(false);
  }, [key]);

  return {
    value,
    setValue: handleSetValue,
    removeValue: handleRemoveValue,
    loading,
    error,
  };
}

@pke
Copy link

pke commented Feb 26, 2022

I have created #760 to fix the current hook and also introduce a proper hook, which supports basically what everbody expected the hook to do in the first place: exposing a current value and an update function.

@seeden Thansk for your version! Your version could be improved by combining the states into one and with all the various versions of hooks I have seen suggested here, I always wondered what the client code is supposed to do in the error case. Face the user with it and offer a "retry" action to save the setting again? What if not one but multiple items can't be saved?

Also my version does not contain loading state, cause I think one should not be seeing storage values to be loaded, it should be fast.

Please join the discussion of the PR and maybe we can include loading and error states.

Or maybe we use getter and setter for the value and you can just write

const setting = useAsyncStorageValue("setting")

// update the setting via setter
setting = "new value"

Of course that would not allow one to use the setting to be directly assigned to an <Input> value/onChange scenario.

@walidsahli
Copy link

Hello, i was facing the consistency of a value for a specific key across multiple components when updating, so i'm using this implementation, which works fine for me.

import AsyncStorage from '@react-native-async-storage/async-storage';
import {useEffect, useState} from 'react';

const defaultLoadTransformer = value => value;
const defaultSetTransformer = value => {
  if (typeof value === 'string') {
    return value;
  } else {
    return JSON.stringify(value);
  }
};

class Observer {
  subscribers = new Map();
  static instance = null;

  static getInstance() {
    if (Observer.instance === null) {
      Observer.instance = new Observer();
    }
    return Observer.instance;
  }

  subscribe(key, cb) {
    if (this.subscribers.has(key)) {
      this.subscribers.get(key).push(cb);
    } else {
      this.subscribers.set(key, [cb]);
    }
    cb(); // call callback on subscribe to get latest data
  }

  notify(key) {
    if (this.subscribers.has(key)) {
      this.subscribers.get(key).forEach(cb => cb());
    }
  }

  clear(key, cb) {
    const listeners = this.subscribers.get(key);
    const filteredlisteners = listeners.filter(fn => cb !== fn);
    if (filteredlisteners.length) {
      this.subscribers.set(key, filteredlisteners);
    } else {
      this.subscribers.delete(key);
    }
  }
}

const useAsyncStorage = (
  key,
  params = {postLoad: defaultLoadTransformer, preSet: defaultSetTransformer},
) => {
  const [item, setItem] = useState(null);

  const onLoadTransformValue = params.postLoad
    ? params.postLoad
    : defaultLoadTransformer;

  const onSetTransformValue = params.preSet
    ? params.preSet
    : defaultSetTransformer;

  const removeElement = async () => {
    await AsyncStorage.removeItem(key);
    Observer.getInstance().notify(key);
  };

  const setElement = async value => {
    await AsyncStorage.setItem(key, onSetTransformValue(value));
    Observer.getInstance().notify(key);
  };

  useEffect(() => {
    const loadElement = async () => {
      const element = await AsyncStorage.getItem(key);
      setItem(element);
    };
    Observer.getInstance().subscribe(key, loadElement);
    return () => {
      Observer.getInstance().clear(key, loadElement);
    };
  }, []);

  return {
    item: onLoadTransformValue(item),
    setItem: setElement,
    removeItem: removeElement,
  };
};

export default useAsyncStorage;

and these are some use examples;

const {item, setItem} = useAsyncStorage(
    'key',
  );

or also if we want to parse the returned data we can do;

  const {item, setItem} = useAsyncStorage(
    'key',
    {postLoad: json => JSON.parse(json)},
  );

@zwilderrr
Copy link

zwilderrr commented May 23, 2023

Alright, I'll throw my hat in the ring here too. This hook updates the component onFocus, which is useful if components on seperate screens are updating the same storage value and the user swipes back (in which case a new render isn't triggered)

import { useCallback, useEffect, useState } from "react";

import AsyncStorage from "@react-native-async-storage/async-storage";
import { useFocusEffect } from "@react-navigation/native";

export function useAsyncStorage<T>(
	key: string,
	defaultValue: T
): [T, (nextValue: T) => Promise<void>] {
	const [value, setValue] = useState(defaultValue);
	const [updated, setUpdated] = useState(false);

	useFocusEffect(
		useCallback(() => {
			getStorageValue();
		}, [])
	);

	useEffect(() => {
		if (!updated) {
			getStorageValue();
		}
	}, [updated]);

	async function getStorageValue() {
		let nextValue = defaultValue;
		const fromStorage = await AsyncStorage.getItem(key);
		if (fromStorage) {
			nextValue = JSON.parse(fromStorage);
		}
		setValue(nextValue);
		setUpdated(true);
	}

	async function setStorageValue(nextValue: T) {
		await AsyncStorage.setItem(key, JSON.stringify(nextValue));
		setUpdated(false);
	}

	return [value, setStorageValue];
}

@abumalick
Copy link

Alright, I'll throw my hat in the ring here too. This hook updates the component onFocus, which is useful if two components are updating the same storage value and the user swipes back (in which case a new render isn't triggered)

I would put the state in a context provider for this, so I get a global state that I can use everywhere in my app. Thinking about possible side effects of writing and reading from storage is horrible. Also what happens if you need to use the state two times in the same screen?

@zwilderrr
Copy link

zwilderrr commented May 23, 2023

I would put the state in a context provider for this, so I get a global state that I can use everywhere in my app. Thinking about possible side effects of writing and reading from storage is horrible. Also what happens if you need to use the state two times in the same screen?

Thanks for the feedback.

Using Context can be good, except that the entire app rerenders which can be undesirable. but you're correct - two components on the same screen pointing to the same key in asyncStorage will not update each other.

@LonelyCpp
Copy link

LonelyCpp commented Jul 31, 2023

I can't think of a solution to the "always returns default value during 1st render" problem without implementing a "sync access" to storage. (similar to fs.readfilesync())

2 active hooks that read from the same key will also not know if the other hook changes the value in storage.

we can maybe implement

  • sync access to store
  • a subscribable in-memory store for hooks to directly read from & update.
  • change in the store can trigger a sync to storage to persist data

this store can also notify all active hooks about a change made to a key from anywhere in the app.

@agrawalishaan
Copy link

If I am using the useAsyncStorage hook, and multiple components use the same key:value pair, and in one component I update the pair with the setItem provided by the hook, do all my components update?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted :octocat: question General question about usage
Projects
None yet
Development

No branches or pull requests