react-native component lifecycle methods not firing on navigation

11.7k Views Asked by At

I am having an issue where I setState in multiple components based on the same key in AsyncStorage. Since the state is set in componentDidMount, and these components don't necessarily unmount and mount on navigation, the state value and the AsyncStorage value can get out of sync.

Here is the simplest example I could make.

Component A

A just sets up the navigation and app.

var React = require('react-native');
var B = require('./B');

var {
    AppRegistry,
    Navigator
} = React;

var A = React.createClass({
    render() {
        return (
            <Navigator
                initialRoute={{
                    component: B
                }}
                renderScene={(route, navigator) => {
                    return <route.component navigator={navigator} />;
                }} />
        );
    }
});

AppRegistry.registerComponent('A', () => A);

Component B

B reads from AsyncStorage on mount, and then sets to state.

var React = require('react-native');
var C = require('./C');

var {
    AsyncStorage,
    View,
    Text,
    TouchableHighlight
} = React;

var B = React.createClass({
    componentDidMount() {
        AsyncStorage.getItem('some-identifier').then(value => {
            this.setState({
                isPresent: value !== null
            });
        });
    },

    getInitialState() {
        return {
            isPresent: false
        };
    },

    goToC() {
        this.props.navigator.push({
            component: C
        });
    },

    render() {
        return (
            <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
                <Text>
                    {this.state.isPresent
                        ? 'Value is present'
                        : 'Value is not present'}
                </Text>

                <TouchableHighlight onPress={this.goToC}>
                    <Text>Click to go to C</Text>
                </TouchableHighlight>
            </View>
        );
    }
});

module.exports = B;

Component C

C reads the same value from AsyncStorage as B, but allows you to change the value. Changing toggles both the value in state and in AsyncStorage.

var React = require('react-native');
var {
    AsyncStorage,
    View,
    Text,
    TouchableHighlight
} = React;

var C = React.createClass({
    componentDidMount() {
        AsyncStorage.getItem('some-identifier').then(value => {
            this.setState({
                isPresent: value !== null
            });
        });
    },

    getInitialState() {
        return {
            isPresent: false
        };
    },

    toggle() {
        if (this.state.isPresent) {
            AsyncStorage.removeItem('some-identifier').then(() => {
                this.setState({
                    isPresent: false
                });
            })
        } else {
            AsyncStorage.setItem('some-identifier', 'some-value').then(() => {
                this.setState({
                    isPresent: true
                });
            });
        }
    },

    goToB() {
        this.props.navigator.pop();
    },

    render() {
        return (
            <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
                <Text>
                    {this.state.isPresent
                        ? 'Value is present'
                        : 'Value is not present'}
                </Text>

                <TouchableHighlight onPress={this.toggle}>
                    <Text>Click to toggle</Text>
                </TouchableHighlight>

                <TouchableHighlight onPress={this.goToB}>
                    <Text>Click to go back</Text>
                </TouchableHighlight>
            </View>
        );
    }
});

module.exports = C;

If you toggle in C and then return to B, the state in B and value in AsyncStorage are now out of sync. As far as I can tell, navigator.pop() does not trigger any component lifecycle functions I can use to tell B to refresh the value.

One solution I am aware of, but isn't ideal, is to make B's state a prop to C, and give C a callback prop to toggle it. That would work well if B and C would always be directly parent and child, but in a real app, the navigation hierarchy could be much deeper.

Is there anyway to trigger a function on a component after a navigation event, or something else that I'm missing?

5

There are 5 best solutions below

0
On

I think the solution to that should be wrapper around the AsyncStorage and possibly using "flux" architecture. https://github.com/facebook/flux with the help of Dispatcher and Event Emitters - very similar to flux chat example: https://github.com/facebook/flux/tree/master/examples/flux-chat

First and most important. As noted in AsyncStorage docs: https://facebook.github.io/react-native/docs/asyncstorage.html

It is recommended that you use an abstraction on top of AsyncStorage instead of AsyncStorage directly for anything more than light usage since it operates globally.

So I believe you should build your own specific "domain" storage which will wrap the generic AsyncStorage and will do the following (see storages in the chat example):

  1. expose specific methods (properties maybe?) for changing the values (any change should trigger "change" events after async change finishes)
  2. expose specific methods (properties maybe?) for reading the values (those could become properties read synchronously as long as the domain storage caches the values after the async change finishes)
  3. expose a "register for change" method (so that components that need to respond to change can register)
  4. in such "change" event handler the component's state should be set as read from the storage (reading the property)
  5. last but not least - I think it's best if following react's patterns, you make changes to the storage's through Dispatcher (part of flux). So rather than components calling the "change" method directly to the "domain storage", they generate "actions" and then the "domain" storage should handle the actions by updating it's stored values (and triggering change events consequently)

That might seem like an overkill first, but It solves a number of problems (including cascading updates and the like - which will be apparent when the app becomes bigger - and it introduces some reasonable abstractions that seem to make sense. You could probably to just points 1-4 without introducing the dispatcher as well - should still work but can later lead to the problems described by Facebook (read the flux docs for more details).

2
On

If you use NavigatorIOS then it can pass an underlying navigator to each child component. That has a couple of events you can use from within those components:

onDidFocus function

Will be called with the new route of each scene after the transition is complete or after the initial mounting

onItemRef function

Will be called with (ref, indexInStack, route) when the scene ref changes

onWillFocus function

Will emit the target route upon mounting and before each nav transition

https://facebook.github.io/react-native/docs/navigator.html#content

0
On

Ran into the same issue as you. I hope this gets changed, or we get an extra event on components to listen to (componentDidRenderFromRoute) or something like that. Anyways, how I solved it was keeping my parent component in scope, so the child nav bar can call a method on the component. I'm using: https://github.com/Kureev/react-native-navbar, but it's simply a wrapper around Navigator:

class ProjectList extends Component {
  _onChange(project) {
    this.props.navigator.push({
      component: Project,
      props: { project: project },
      navigationBar: (<NavigationBar
        title={project.title}
        titleColor="#000000"
        style={appStyles.navigator}
        customPrev={<CustomPrev onPress={() => {
          this.props.navigator.pop();
          this._sub();
        }} />}
      />)
    });
  }
}

I'm pushing a project component with prop data, and attached my navigation bar component. The customPrev is what the react-native-navbar will replace with its default. So in its on press, i invoke the pop, and call the _sub method on my ProjectList instance.

0
On

1) The main problem here is in your architecture - you need to create some wrapper around AsyncStorage that also generates events when some value is changed, example of class interface is:

class Storage extends EventEmitter {
    get(key) { ... }
    set(key, value) { ... }
}

In componentWillMount:

storage.on('someValueChanged', this.onChanged);

In componentWillUnmount:

storage.removeListener('someValueChanged', this.onChanged);

2) Architecture problem can be also fixed by using for example redux + react-redux, with it's global app state and automatic re-rendering when it is changed.

3) Other way (not event-based, so not perfect) is to add custom lifecycle methods like componentDidAppear and componentDidDissapear. Here is example BaseComponent class:

import React, { Component } from 'react';

export default class BaseComponent extends Component {
    constructor(props) {
        super(props);
        this.appeared = false;
    }

    componentWillMount() {
        this.route = this.props.navigator.navigationContext.currentRoute;
        console.log('componentWillMount', this.route.name);

        this.didFocusSubscription = this.props.navigator.navigationContext.addListener('didfocus', event => {
            if (this.route === event.data.route) {
                this.appeared = true;
                this.componentDidAppear();
            } else if (this.appeared) {
                this.appeared = false;
                this.componentDidDisappear();
            }
        });
    }

    componentDidMount() {
        console.log('componentDidMount', this.route.name);
    }

    componentWillUnmount() {
        console.log('componentWillUnmount', this.route.name);
        this.didFocusSubscription.remove();
        this.componentDidDisappear();
    }

    componentDidAppear() {
        console.log('componentDidAppear', this.route.name);
    }

    componentDidDisappear() {
        console.log('componentDidDisappear', this.route.name);
    }
}

So just extend from that component and override componentDidAppear method (Don't forget about OOP and call super implementation inside: super.componentdDidAppear()).

0
On

My solution is trying to add my custom life cycle for component.

this._navigator.addListener('didfocus', (event) => {
  let component = event.target.currentRoute.scene;
  if (component.getWrappedInstance !== undefined) {
    // If component is wrapped by react-redux
    component = component.getWrappedInstance();
  }
  if (component.componentDidFocusByNavigator !== undefined &&
      typeof(component.componentDidFocusByNavigator) === 'function') {
    component.componentDidFocusByNavigator();
  }
});

Then you can add componentDidFocusByNavigator() in your component to do something.