Recording redux navigation state in model state?

Viewed 370

I've structured my redux application such that my data models are handled on separate branches of the state tree.

{concerts, venues}

I've also used react-navigation-redux-helpers to put my navigation state into the tree:

{concerts, venues, nav}

However, I want to record information about the visibility state of a particular model. When the ConcertScreen is shown, I want to know when a user's looking/stops looking at a particular concert ID (and letting the server know), with the eventual goal of measuring how long a particular concert ID was visible on screen.

I've done this by adding branches for Navigation/NAVIGATE, Navigation/RESET, and Navigation/BACK to the concerts reducer, and setting visible: true on the appropriate object under concerts.

This has been error prone, since the navigation state can be modified by actions other than these specific actions. (A logout action handled directly by the nav reducer, for example.)

I see two alternatives, both not ideal:

  1. Use props.navigation.addListener to listen to focus and blur events on the ConcertScreen, trigger custom concertFocused/concertBlurred actions, and handle those in my concert reducer instead of the Navigation/* actions.

  2. Create a selector that computes the currently visible concert from the nav state and refactor the business logic that expects concert.visible as input to use the selector instead.

The problem with 1 seems to be that it's adding overhead to the event loop, all the extra actions flying around means extra rendering overhead.

2 avoids the extra actions, but it seems like a lot of refactoring for not a whole lot of gain, and it means I have to move business logic out of the concert reducer and put it elsewhere.

Say I use option 2. I add a middleware that, on any action, applies the selector to state.nav and from that computes what Concert is currently displayed. If I wanted to measure duration, how would I store start/end time? Fire a new action with that added data so the concert reducer catches it? That just seems like option 1 with extra steps.

I could also have this middleware add a field to every action indicating the concert display state, and have the concert reducer handle it in the default/fallthrough case. Do people do that?

1 Answers

I would approach your use-case in such a way, that I will get the best of both solutions.

First of all, having many actions dispatched you're worried about rendering overhead. Using a selector library, let's say reselect, the library memoization will prevent unnecessarily components rerendering.

Later on, if I understand you correctly, your goal is to let the server know the visibility status of an item (concert) and eventually its visible time. If your goal is notifying the server only, without letting know the rest app's front-end users, why do you want to keep tracking it in Redux too? You can skip the Redux part and send updates to the server only.

Let's assume, that you need Redux for the tracking. You can try on your way structuring the Store, as you already mentioned, adding the visible flag to each object in the Redux store. But if your items' structure is bigger enough and it's costly to copy and update the object each time when changing the visible flag, you can consider creating a dedicated Store branch and reducer, that will be responsible only for the tracking needs. Something like that:

tracking : {
  concerts: {
    1: { visible: true, time: 10 }
  }
}

Now, updating an item's flag, only the above tiny structure has to be copied and modified. Even, you can make it smaller and more specific for a certain item type (trackingConcerts).

* Keep in mind, it's on your own to decide whether or not such a dedicated Store branch will improve the performance, because we don't know your detailed architecture and Store specifics.

Continuing with the solutions ...

Relying on navigation actions + middleware is error prone, as you mentioned. What about the use-case you have a general page of components (i.e. navigation action with generic name will be dispatched), but you render there one of your items (concert)? Also rendering an item, would be always coupled with modifying the mapping logic in your middleware or wherever place you track the items by action name. Another tricky case is when you render different type of items (concerts, venues) on exactly one page. How will you differ and track the items, considering you have only 1 navigation item? Also in a such setup, I don't see a straightforward way for handling an item visible time.

About the selectors as a solution - they can be only a small part of the solution. The selector is responsible for selecting and managing derived state. Nothing more.

Show me the code, please.

I would create a wrapper component around react on screen (or any similar library that tracks component visibility) and implement only the tracking visible time of the component.

The wrapper will trigger callbacks when the component visibility state is changed and a callback on componentDidUnmount including the visible time.

That's all! Now you can attach handlers on these callbacks and you can update your Redux and/or notify the server for the visibility changes, without relying on any navigation actions and middlewares.

Usage:

const App = () => (
  <Tracking
    onVisibilityChange={isVisible => {}}
    onUnmount={visibleSeconds => {}}
  >
    <Concert id={1} />
  </Tracking>
)

Tracking Wrapper:

import TrackVisibility from 'react-on-screen'

const Tracking = ({ children, libraryProps, ...rest }) => (
  <TrackVisibility {...libraryProps}>
    <TrackingCore {...rest}>
      {children}
    </TrackingCore>
  </TrackVisibility>
)

TrackingCore, our custom tracking logic:

class TrackingCore extends React.Component {
  constructor (props) {
    super(props)

    this.state = {
      visibleSeconds: 0,
      interval: null
    }
  }

  componentDidMount() {
    this.track()
  }

  componentWillReceiveProps (nextProps) {
    this.track(nextProps)
  }

  componentDidUnmount() {
    const { visibleSeconds, interval } = this.state
    const { onUnmount } = this.props

    onUnmount(visibleSeconds)
    clearInterval(interval)
  }

  track (nextProps) {
    const { isVisible, onVisibilityChange } = this.props
    const { visibleSeconds, interval } = this.state

    const hasVisibilityChanged = (isVisible !== nextProps.isVisible) || !nextProps
    const isVisibleValue = nextProps ? nextProps.isVisible : isVisible

    // On visibility change, invoke the callback prop
    if (hasVisibilityChanged) {
      onVisibilityChange(isVisibleValue)

      // If it becomes visible, start counting the `visibleSeconds`
      if (isVisibleValue) {
        this.setState({
          interval: setInterval(() => this.setState({
            visibleSeconds: visibleSeconds + 1
          }), 1000)
        })
      } else {
        clearInterval(interval)
      }
    }
  }

  render () {
    return this.props.children
  }
}
Related