feat: add a useFocusEffect hook

This commit is contained in:
satyajit.happy
2019-07-30 17:18:09 +02:00
committed by Satyajit Sahoo
parent fb8d3024bf
commit 819b7904fa
5 changed files with 366 additions and 25 deletions

View File

@@ -66,8 +66,8 @@ If an initial state is specified, e.g. as a result of `Linking.getInitialURL()`,
## Basic usage
```js
const Stack = StackNavigator();
const Tab = TabNavigator();
const Stack = createStackNavigator();
const Tab = createTabNavigator();
function App() {
return (
@@ -163,6 +163,45 @@ The `target` property determines the screen that will receive the event. If the
Screens cannot emit events as there is no `emit` method on a screen's `navigation` prop.
If you don't need to get notified of focus change, but just need to check if the screen is currently focused in a callback, you can use the `navigation.isFocused()` method which returns a boolean. Note that it's not safe to use this in `render`. Only use it in callbacks, event listeners etc.
## Side-effects in focused screen
Sometimes we want to run side-effects when a screen is focused. A side effect may involve things like adding an event listener, fetching data, updating document title, etc. While this can be achieved using `focus` and `blur` events, it's not very ergonomic.
To make this easier, the library exports a `useFocusEffect` hook:
```js
function Profile({ userId }) {
const [user, setUser] = React.useState(null);
const fetchUser = React.useCallback(() => {
const request = API.fetchUser(userId).then(
data => setUser(data),
error => alert(error.message)
);
return () => request.abort();
}, [userId]);
useFocusEffect(fetchUser);
return <ProfileContent user={user} />;
}
```
The `useFocusEffect` is analogous to React's `useEffect` hook. The only difference is that it runs on focus instead of render.
**NOTE:** To avoid the running the effect too often, it's important to wrap the callback in `useCallback` before passing it to `useFocusEffect` as shown in the example.
## Access navigation anywhere
Passing the `navigation` prop down can be tedious. The library exports a `useNavigation` hook which can access the `navigation` prop from the parent screen.
```js
const navigation = useNavigation();
```
## Type-checking
The library exports few helper types. Each navigator also need to export a custom type for the `navigation` prop which should contain the actions they provide, .e.g. `push` for stack, `jumpTo` for tab etc.
@@ -223,6 +262,12 @@ type FeedScreenNavigationProp = CompositeNavigationProp<
>;
```
To annotate the `navigation` prop from `useNavigation`, we can use a type parameter:
```ts
const navigation = useNavigation<FeedScreenNavigationProp>();
```
It's also possible to type-check the navigator to some extent. To do this, we need to pass a generic when creating the navigator object:
```ts

View File

@@ -6,6 +6,7 @@ import {
NavigationHelpers,
RouteProp,
InitialState,
useFocusEffect,
} from '../src';
import createStackNavigator, { StackNavigationProp } from './StackNavigator';
import createTabNavigator, { TabNavigationProp } from './TabNavigator';
@@ -34,29 +35,39 @@ const First = ({
NavigationHelpers<TabParamList>
>;
route: RouteProp<StackParamList, 'first'>;
}) => (
<div>
<h1>First, {route.params.author}</h1>
<button type="button" onClick={() => navigation.push('second')}>
Push second
</button>
<button type="button" onClick={() => navigation.push('third')}>
Push third
</button>
<button type="button" onClick={() => navigation.navigate('fourth')}>
Navigate to fourth
</button>
<button
type="button"
onClick={() => navigation.navigate('first', { author: 'John' })}
>
Navigate with params
</button>
<button type="button" onClick={() => navigation.pop()}>
Pop
</button>
</div>
);
}) => {
const updateTitle = React.useCallback(() => {
document.title = `${route.name} (${route.params.author})`;
return () => (document.title = '');
}, [route.name, route.params.author]);
useFocusEffect(updateTitle);
return (
<div>
<h1>First, {route.params.author}</h1>
<button type="button" onClick={() => navigation.push('second')}>
Push second
</button>
<button type="button" onClick={() => navigation.push('third')}>
Push third
</button>
<button type="button" onClick={() => navigation.navigate('fourth')}>
Navigate to fourth
</button>
<button
type="button"
onClick={() => navigation.navigate('first', { author: 'John' })}
>
Navigate with params
</button>
<button type="button" onClick={() => navigation.pop()}>
Pop
</button>
</div>
);
};
const Second = ({
navigation,

View File

@@ -0,0 +1,241 @@
import * as React from 'react';
import { render, act } from 'react-native-testing-library';
import useNavigationBuilder from '../useNavigationBuilder';
import useFocusEffect from '../useFocusEffect';
import NavigationContainer from '../NavigationContainer';
import Screen from '../Screen';
import MockRouter from './__fixtures__/MockRouter';
it('runs focus effect on focus change', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return state.routes.map(route => descriptors[route.key].render());
};
const focusEffect = jest.fn();
const focusEffectCleanup = jest.fn();
const Test = () => {
const onFocus = React.useCallback(() => {
focusEffect();
return focusEffectCleanup;
}, []);
useFocusEffect(onFocus);
return null;
};
const navigation = React.createRef<any>();
const element = (
<NavigationContainer ref={navigation}>
<TestNavigator>
<Screen name="first">{() => null}</Screen>
<Screen name="second" component={Test} />
<Screen name="third">{() => null}</Screen>
</TestNavigator>
</NavigationContainer>
);
render(element);
expect(focusEffect).not.toBeCalled();
expect(focusEffectCleanup).not.toBeCalled();
act(() => navigation.current.navigate('second'));
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).not.toBeCalled();
act(() => navigation.current.navigate('third'));
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).toBeCalledTimes(1);
act(() => navigation.current.navigate('second'));
expect(focusEffect).toBeCalledTimes(2);
expect(focusEffectCleanup).toBeCalledTimes(1);
});
it('runs focus effect on deps change', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const focusEffect = jest.fn();
const focusEffectCleanup = jest.fn();
const Test = ({ count }: { count: number }) => {
const onFocus = React.useCallback(() => {
focusEffect(count);
return focusEffectCleanup;
}, [count]);
useFocusEffect(onFocus);
return null;
};
const App = ({ count }: { count: number }) => (
<NavigationContainer>
<TestNavigator>
<Screen name="first">{() => <Test count={count} />}</Screen>
<Screen name="second">{() => null}</Screen>
</TestNavigator>
</NavigationContainer>
);
const root = render(<App count={1} />);
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).not.toBeCalled();
root.update(<App count={2} />);
expect(focusEffectCleanup).toBeCalledTimes(1);
expect(focusEffect).toBeCalledTimes(2);
});
it('runs focus effect when initial state is given', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return state.routes.map(route => descriptors[route.key].render());
};
const focusEffect = jest.fn();
const focusEffectCleanup = jest.fn();
const Test = () => {
useFocusEffect(() => {
focusEffect();
return focusEffectCleanup;
});
return null;
};
const initialState = {
index: 2,
routes: [
{ key: 'first', name: 'first' },
{ key: 'second', name: 'second' },
{ key: 'third', name: 'third' },
],
};
const navigation = React.createRef<any>();
const element = (
<NavigationContainer ref={navigation} initialState={initialState}>
<TestNavigator>
<Screen name="first">{() => null}</Screen>
<Screen name="second">{() => null}</Screen>
<Screen name="third" component={Test} />
</TestNavigator>
</NavigationContainer>
);
render(element);
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).not.toBeCalled();
act(() => navigation.current.navigate('first'));
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).toBeCalledTimes(1);
});
it('runs focus effect when only focused route is rendered', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const focusEffect = jest.fn();
const focusEffectCleanup = jest.fn();
const Test = () => {
useFocusEffect(() => {
focusEffect();
return focusEffectCleanup;
});
return null;
};
const navigation = React.createRef<any>();
const element = (
<NavigationContainer ref={navigation}>
<TestNavigator>
<Screen name="first" component={Test} />
<Screen name="second">{() => null}</Screen>
<Screen name="third">{() => null}</Screen>
</TestNavigator>
</NavigationContainer>
);
render(element);
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).not.toBeCalled();
act(() => navigation.current.navigate('second'));
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).toBeCalledTimes(1);
});
it('runs cleanup when component is unmounted', () => {
const TestNavigator = (props: any): any => {
const { state, descriptors } = useNavigationBuilder(MockRouter, props);
return descriptors[state.routes[state.index].key].render();
};
const focusEffect = jest.fn();
const focusEffectCleanup = jest.fn();
const TestA = () => {
useFocusEffect(() => {
focusEffect();
return focusEffectCleanup;
});
return null;
};
const TestB = () => null;
const App = ({ mounted }: { mounted: boolean }) => (
<NavigationContainer>
<TestNavigator>
<Screen name="first" component={mounted ? TestA : TestB} />
<Screen name="second">{() => null}</Screen>
</TestNavigator>
</NavigationContainer>
);
const root = render(<App mounted />);
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).not.toBeCalled();
root.update(<App mounted={false} />);
expect(focusEffect).toBeCalledTimes(1);
expect(focusEffectCleanup).toBeCalledTimes(1);
});

View File

@@ -4,5 +4,6 @@ export { default as createNavigator } from './createNavigator';
export { default as useNavigationBuilder } from './useNavigationBuilder';
export { default as useNavigation } from './useNavigation';
export { default as useFocusEffect } from './useFocusEffect';
export * from './types';

43
src/useFocusEffect.tsx Normal file
View File

@@ -0,0 +1,43 @@
import * as React from 'react';
import useNavigation from './useNavigation';
type EffectCallback = (() => undefined) | (() => () => void);
export default function useFocusEffect(callback: EffectCallback) {
const navigation = useNavigation();
React.useEffect(() => {
let isFocused = false;
let cleanup: (() => void) | undefined;
// We need to run the effect on intial render/dep changes if the screen is focused
if (navigation.isFocused()) {
cleanup = callback();
isFocused = true;
}
const unsubscribeFocus = navigation.addListener('focus', () => {
// If callback was already called for focus, avoid calling it again
// The focus event may also fire on intial render, so we guard against runing the effect twice
if (isFocused) {
return;
}
cleanup && cleanup();
cleanup = callback();
isFocused = true;
});
const unsubscribeBlur = navigation.addListener('blur', () => {
cleanup && cleanup();
cleanup = undefined;
isFocused = false;
});
return () => {
cleanup && cleanup();
unsubscribeFocus();
unsubscribeBlur();
};
}, [callback, navigation]);
}