vercel / swr

React Hooks for Data Fetching
https://swr.vercel.app
MIT License
30.63k stars 1.22k forks source link

Better support for react-native #417

Closed outaTiME closed 3 years ago

outaTiME commented 4 years ago

Hi guys, it could be great to use AppState (https://reactnative.dev/docs/appstate) for revalidateOnFocus and NetInfo (https://github.com/react-native-community/react-native-netinfo) for revalidateOnReconnect, errorRetryInterval, loadingTimeout when running in react-native context.

Any advice with that? im handling revalidations manually for this cases.

Thanks !!!

huozhi commented 4 years ago

propose a solution for it

Situation

now we have web "online" event for detecting network connect state; web "visibilitychange" and "focus" event for detecting focus state;

Implementation

we can encapsulate them as default configs in swr config. i.e.

const defaultIsVisible = () => isdocumentvisible() && isonline()
const defaultFocusDetector = (revalidate) => {
   // this function is overridable by configuration
   if (window.addEventListener) {
     window.addEventListener('...', revalidate, ...)
     // ...
   }
}

const defaultNetworkConnectionDetector = (onConnect) => {
   .... // this function is overridable by configuration
}

config.networkConnectionDetector = defaultNetworkConnectionDetector;
config.focusDetector = defaultFocusDetector;
config.isVisible = defaultIsVisible;

default handlers are for web only, but you can override them through swr configuration setting

and we pass the revalidate or reconnect handlers to them

in use-swr.ts we can do

if (!IS_SERVER && config.networkConnectionDetector && config.revalidateOnReconnect) {
  config.networkConnectionDetector(reconnect = softRevalidate)
}

in config.ts we could also setup focus detector

// focus revalidate
let eventsbinded = false
if (typeof window !== 'undefined' && defaultConfig.focusDetector && !eventsbinded) {
  const revalidate = () => {
     if (!config.isFocusing()) return
    ...
  }
  defaultConfig.focusDetector(revalidate)
  // only bind the events once
  eventsbinded = true
}

Usage

import React, { useEffect, useState } from "react";
import { AppState } from "react-native";
import useSWR from 'swr'

const AppStateExample = () => {
  const [appState, setAppState] = useState(AppState.currentState);

  const { data } = useSWR(key, fetcher, {
     focusDetector(revalidate) {
       handleStateChange = (...) => { if (...) revalidate }
       AppState.addEventListener("change", handleStateChange);
         return () => {
           AppState.removeEventListener("change", handleStateChange);
         }
      }
  })

// ...

Not sure if it's ok to do so. wanna get more feedbacks from community and maintainers. if it's doable I can try to submit a PR for that. cc @shuding

outaTiME commented 4 years ago

@huozhi great explanation it looks wired but it could work,

enhancement on usage (reavalidation must ocurr only when foreground imo):

import React, { useEffect, useState } from "react";
import { AppState } from "react-native";
import useSWR from 'swr'

const AppStateExample = () => {
  const [appState, setAppState] = useState(AppState.currentState);
  const { data } = useSWR(key, fetcher, {
    focusDetector(revalidate) {
      const _handleAppStateChange = nextAppState => {
        if (appState.match(/inactive|background/) && nextAppState === "active") {
          console.log("App has come to the foreground!");
          revalidate();
        }
        setAppState(nextAppState);
      };
      AppState.addEventListener("change", _handleAppStateChange);
      return () => {
        AppState.removeEventListener("change", _handleAppStateChange);
      }
    } 
  })
}

note: focusDetector need to be recreated in each render to works as expected how useSWR options works in this case? and what about of errorRetryInterval, loadingTimeout (they are related to connection too).

huozhi commented 4 years ago

@outaTiME thanks for the clear example for app state detection. 👍

for errorRetryInterval and loadingTime, they're numbers but related to the network state on slow or fast network. that also could rely on some platform layer API to detect if it's slow. probably we need on more callback for that for react native or other runtime, such as using NetInfoCellularGeneration value from react-native-netinfo.

the idea is to decouple the platform runtime specific part out of swr, like the listeners on window in browser runtime.

OscarYuen commented 4 years ago

It would great if we could use our customised rule to determine when the data needs to be refreshed

jaworek commented 4 years ago

I think this issue answers my question: https://github.com/vercel/swr/discussions/614

pke commented 4 years ago

Any of you @outaTiME @huozhi working on a PR for that?

huozhi commented 4 years ago

there's a RFC to support plugin/middleware pattern in swr to make it more cross-platform adaptive. the APIs are not settled yet and discussions are welcomed. once we had a good form of APIs and certain bahaviors of plugin/middleware, we'll surly start the support for RN ASAP

pke commented 4 years ago

So I assume with 0.3.7 this could also be used in global config?

huozhi commented 4 years ago

seems it's more complicated, the latest changes are not enough : (. we need to give swr ability to customize the way to listen on focus/visible/connect events. need more changes on that. will bump here later once we have more support on it.

nandorojo commented 4 years ago

Update published my solution to npm here: https://github.com/nandorojo/swr-react-native

FWIW, I made useSWRReactNavigation. I just wrote it and haven't tested it in my app yet. Once I test it out I can publish it to NPM.

Usage

const { data, revalidate } = useSWR(key, fetcher)

useSWRReactNavigation({
  revalidate
})

You can also pass options to it:

const { data, revalidate } = useSWR(key, fetcher)

useSWRReactNavigation({
  revalidate
  // optional, defaults copied from SWR
  revalidateOnFocus: true,
  revalidateOnReconnect: true,
  focusThrottleInterval: 5000,
})

Code

import { responseInterface, ConfigInterface } from 'swr'
import { useRef, useEffect } from 'react'
import { AppState } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import NetInfo, { NetInfoState } from '@react-native-community/netinfo'

type Props<Data, Error> = {
  /**
   * Required: pass the `revalidate` function returned to you by SWR.
   */
  revalidate: responseInterface<Data, Error>['revalidate']
} & Pick<
  ConfigInterface,
  'revalidateOnFocus' | 'revalidateOnReconnect' | 'focusThrottleInterval'
>

import { Platform } from 'react-native'

/**
 * swr-react-native
 *
 * This helps you revalidate your SWR calls, based on navigation actions in `react-navigation`.
 */
export default function useSWRReactNavigation<Data = any, Error = any>(
  props: Props<Data, Error>
) {
  const {
    revalidate,
    // copy defaults from SWR
    revalidateOnFocus = true,
    revalidateOnReconnect = true,
    focusThrottleInterval = 5000,
  } = props

  const { addListener } = useNavigation()

  const lastFocusedAt = useRef<number | null>(null)
  const fetchRef = useRef(revalidate)
  useEffect(() => {
    fetchRef.current = revalidate
  })
  const focusCount = useRef(0)

  const previousAppState = useRef(AppState.currentState)
  const previousNetworkState = useRef<NetInfoState | null>(null)

  useEffect(() => {
    // SWR does all of this on web.
    if (Platform.OS === 'web') return

    let unsubscribeReconnect: ReturnType<
      typeof NetInfo.addEventListener
    > | null = null
    if (revalidateOnReconnect && typeof window !== 'undefined') {
      unsubscribeReconnect = NetInfo.addEventListener((state) => {
        if (
          previousNetworkState.current?.isInternetReachable === false &&
          state.isConnected &&
          state.isInternetReachable
        ) {
          fetchRef.current()
        }
        previousNetworkState.current = state
      })
    }

    const onFocus = () => {
      if (focusCount.current < 1) {
        focusCount.current++
        return
      }
      const isThrottled =
        focusThrottleInterval &&
        lastFocusedAt.current &&
        Date.now() - lastFocusedAt.current <= focusThrottleInterval

      if (!isThrottled) {
        lastFocusedAt.current = Date.now()
        fetchRef.current()
      }
    }

    const onAppStateChange = (nextAppState: AppState['currentState']) => {
      if (
        previousAppState.current.match(/inactive|background/) &&
        nextAppState === 'active'
      ) {
        onFocus()
      }

      previousAppState.current = nextAppState
    }

    let unsubscribeFocus: ReturnType<typeof addListener> | null = null

    if (revalidateOnFocus) {
      unsubscribeFocus = addListener('focus', onFocus)
      AppState.addEventListener('change', onAppStateChange)
    }

    return () => {
      if (revalidateOnFocus) {
        unsubscribeFocus?.()
        AppState.removeEventListener('change', onAppStateChange)
      }
      if (revalidateOnReconnect) {
        unsubscribeReconnect?.()
      }
    }
  }, [
    addListener,
    focusThrottleInterval,
    revalidateOnFocus,
    revalidateOnReconnect,
  ])
}

I'd appreciate help testing it. It listens for 1) network reconnections, 2) app state focus, 3) react navigation focus.

You could also take out the react navigation portion of the code if you don't need that.

te-online commented 4 years ago

@nandorojo Thanks a lot for this useful custom hook! No npm package, yet? 😉 I had to make two changes to make the basics work:

  1. The argument passed to AppState.addEventListener (and AppState.removeEventListener) needs to be change instead of focus, seems like a typo in your version.
...
if (revalidateOnFocus) {
  unsubscribeFocus = addListener('focus', onFocus);
  AppState.addEventListener('change', onAppStateChange);
}

return () => {
  if (revalidateOnFocus) {
    unsubscribeFocus?.();
    AppState.removeEventListener('change', onAppStateChange);
  }
  if (revalidateOnReconnect) {
    unsubscribeReconnect?.();
  }
};
...
  1. new Date().getTime() didn't work as expected for me. It returned seconds. This might be related to anti-fingerprinting settings or simply some other issue. Instead I used Date.now(), but had to change the operator in onFocus. Instead of > use <=.
...
const onFocus = () => {
  const isThrottled =
    focusThrottleInterval &&
    lastFocusedAt.current &&
    Date.now() - lastFocusedAt.current <= focusThrottleInterval;

  if (!isThrottled) {
    lastFocusedAt.current = Date.now();
    fetchRef.current();
  }
};
...

One major issue I'm still facing is a duplicate load on first focus, because react-navigation fires an indistinguishable focus event on first and subsequent focuses and swr is already triggering a fetch on first load of the app.

nandorojo commented 4 years ago

@te-online thanks for finding those issues! I should be able to get it on npm soon.

One major issue I'm still facing is a duplicate load on first focus, because react-navigation fires an indistinguishable focus event on first and subsequent focuses and swr is already triggering a fetch on first load of the app.

If this is always the case, it seems like we could keep track of a focusCount and only trigger it if it's > 1.

te-online commented 4 years ago

@nandorojo I found an added focusCount to be working fine. Here's a gist https://gist.github.com/te-online/4eaa564aa02546ea25247d2f079f935e

nandorojo commented 3 years ago

I finally got around to publishing this to NPM (@nandorojo/swr-react-native)!

The package is a drop-in replacement for SWR, with added support for focus events, reconnection, and react navigation.

Link: https://github.com/nandorojo/swr-react-native

Drop-in usage is as simple as:

- import useSWR from 'swr'
+ import useSWRNative from '@nandorojo/swr-react-native'

Or, you can use it like this:

import { useSWRNativeRevalidate } from '@nandorojo/swr-react-native'

const { data, revalidate } = useSWR(key, fetcher)

useSWRNativeRevalidate({
  // required: pass your revalidate function returned by SWR
  revalidate

  // optional, defaults copied from SWR
  revalidateOnFocus: true,
  revalidateOnReconnect: true,
  focusThrottleInterval: 5000,
})

You might want to use this method if you're using useSWRInfinite, for example.


@te-online thanks for your help testing and fixing this!

huozhi commented 3 years ago

With the release of latest swr beta 1.0.0-beta.9, now I'm able to build the RN example with expo. Please checkout huozhi/swr-rn-example. It provides a simple customization of "focus revalidation" and custom cache usage. I think it could resolve basic problems of RN usage.

javascripter commented 3 years ago

Update: Looking at the new middleware documentation (https://swr.vercel.app/docs/middleware#keep-previous-result), it seems you can call hooks in a middleware. So I believe it's possible to build RN react-navigation integration with middleware system.

For RN I think it'd be more useful if we could register on focus event per useSWR / useSWRInfinite hook to allow focus revalidation per screen, as done in @nandorojo/swr-react-native.

For example, when a user navigates to another screen, it's often useful to revalidate all swr calls in that screen instead of AppState change, which I guess is only possible by calling useFocusEffect per hook in react-navigation.

huozhi commented 3 years ago

🎉 SWR v1 is released. We updated the documentation for using SWR with React Native. Free feel to open new issues for giving feedback of the integration on React Native.

nandorojo commented 3 years ago

Thanks @huozhi!

One issue for React Native remains: knowing if a particular screen is focused.

That can be solved with my library linked above https://github.com/vercel/swr/issues/417#issuecomment-737420642

Perhaps I can release it as a middleware too (although I think its original implementation would work as a middleware anyway!)

In order for a screen to know if it’s focused, you have a hook like this:

const focused = useIsFocused()

The issue is, you have to call this function within the screen. So the SWRConfig‘s global focus checker doesn’t work, since it’s at the root of the app.

Maybe the middleware is the just the best use case here.

huozhi commented 3 years ago

@nandorojo Interesting, thanks for pointing out the limitation of the example! I'll take a look at your library to see how to make the example more resilient. Also feel free to update the swr doc if you have any idea to make the example fit realy world react native app better! 🙏

nandorojo commented 3 years ago

Here's how the middleware could work (simplified a bit).

function withScreenFocus(useSWRNext) {
  return function useSWRFocus(key, fn, opts) {
    const { addListener } = useNavigation()

    const { revalidateOnFocus = opts.revalidateOnFocus ?? true } = useSWRConfig()
    const swr = useSWRNext(key, fn, opts)

    const { mutate } = swr

    useEffect(() => {
       const remove = addListener('focus', () => mutate())
       return () => remove()
     }, [mutate, addListener])

    return swr
  }
}

Then in your app:

useSWR(key, fn, { use: [withScreenFocus] })

Or, globally:

<SWRConfig value={{ ..., use: [withScreenFocus] }} />

The remaining use-cases seem like they're dealt with in the docs.

The one issue with that middleware is, you're manually calling mutate, rather than using the initFocus API. @huozhi do you see any downsides to this?

An alternative could be to expose a hook-specific initFocus:

function withScreenFocus(useSWRNext) {
  return function useSWRFocus(key, fn, opts) {
    const { addListener } = useNavigation()

    return useSWRNext(key, fn, {
      ...opts,
      initFocus(trigger) {
        const unsubscribe = addListener('focus', () =>  trigger())

        return () => unsubscribe()
      }
    })
  }
}

The only issue with that is, it seemingly overrides the globally-set initFocus, rather than extending it.