vercel/swr

Better support for react-native

outaTiME opened this issue Β· 27 comments

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 !!!

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

@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).

@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.

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

I think this issue answers my question:
#614

pke commented

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

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

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

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.

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.

@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.

@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.

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

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!

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.

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.

πŸŽ‰ 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.

Thanks @huozhi!

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

That can be solved with my library linked above #417 (comment)

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.

@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! πŸ™

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.

I think instead of creating it as a middleware, you can create it as a wrapper component (which has SWRConfig inside):

<SWRRNFocus>
  <App/>
</SWRRNFocus>

Which has the initFocus logic inside.

The issue is, you'd need to add this to every single screen. React Navigation tells you if a screen is focused, only within that screen.

That's why a middleware would be necessary; you have to check for screen focus in the same component scope as the SWR hook.

I see. The problem of middleware for this case is, every component will try to register an event listener :(

Is there a way to put this on a higher level, e.g. the component entry, and detect if the current focused screen has changed?

Unfortunately not, since React Navigation allows for many nested navigators (stacks inside of tabs, etc) it’s somewhat impossible to know what your navigation scope is from a hook.

I’m not sure what the overhead is of adding a lot of navigation listeners to each component.

My current implementation is to add it sparingly to certain screens using my library linked to here. Maybe I’ll just stick with that, since it doesn’t need to literally update every hook on screen focus.

This is like the similar problem that why we changed from createCache API to option provider + useSWRConfig. For basic single screen view app, global setup is easy like the current example did. And sometimes we find out that it's hard to work with hook based APIs, in this case is useNavigation.

Since hook now is a widely adopted concept in react community, we might need to rethink of the current API to customize focus and reconnect events detection. @nandorojo In short term, I think the 1st one (using mutate API) is a good choice πŸ‘. Long term I'd prefer to polish the API to support hooks more elegantly.

thgh commented

I'm looking through the source code but can't find the code where it calls the unsubscribe function that's returned in initFocus . It seems like it will never get called.

swr/src/utils/cache.ts

Lines 57 to 58 in 3285cc4

if (!IS_SERVER) {
opts.initFocus(

Yeah @thgh would you mind opening an issue for that? Thanks! We’d like to get it fixed.