Wilfried Barth
Wilfried Barth

Reputation: 13

Implementing Auto-Save in React-Redux-Firebase App using Redux-Observable

I am implementing auto-save for a react-redux-firebase side project using redux-observable. Currently I have a updateFretboardEpic that is responding to any actions that modify a Fretboard component currently possessing a reference (firebaseKey) to the Firebase database. After 1 second debounce, updateFretboard should save the new state of the component to Firebase.

import {
  ADD_STRING,
  REMOVE_STRING,
  INC_STR_PITCH,
  DEC_STR_PITCH,
  ADD_FRET,
  REMOVE_FRET,
  UPDATE_FRETBOARD
} from '../actions/action-types';

import {
  updateFretboard
} from '../actions/fretboard-actions';

export const updateFretboardEpic = (action$, store) => {
  return action$.ofType(
      ADD_STRING,
      REMOVE_STRING,  
      INC_STR_PITCH,
      DEC_STR_PITCH,
      ADD_FRET,
      REMOVE_FRET
    )
    .filter(() => store.getState().fretboard.firebaseKey !== undefined)
    .debounceTime(1000)
    .map(() => updateFretboard(
      store.getState().fretboard.fretboardData,
      store.getState().fretboard.firebaseKey
    ));
};

However, I am currently getting the following error:

Uncaught Error: Actions must be plain objects. Use custom middleware for async actions.
    at Object.performAction (<anonymous>:3:2312)
    at liftAction (<anonymous>:2:27846)
    at dispatch (<anonymous>:2:31884)
    at bundle.ff2509b….js:9896
    at SafeSubscriber.dispatch [as _next] (vendor.ff2509b….js:51341)
    at SafeSubscriber.__tryOrUnsub (bundle.ff2509b….js:392)
    at SafeSubscriber.next (bundle.ff2509b….js:339)
    at Subscriber._next (bundle.ff2509b….js:279)
    at Subscriber.next (bundle.ff2509b….js:243)
    at SwitchMapSubscriber.notifyNext (bundle.ff2509b….js:6579)

Prior to implementing redux-observable, updateFretboard was using Redux-Thunk to dispatch an action:

export function updateFretboard(fretboardData, firebaseKey = undefined) {
  return dispatch => { fretboards.child(firebaseKey).update(fretboardData); };
}

Using that as it stands with redux-observable will produce the error without any auto-save. Instead of returning a thunk, I changed it to this:

export function updateFretboard(fretboardData, firebaseKey = undefined) {
  return fretboards.child(firebaseKey).update(fretboardData);
}

Interestingly, updateFretboardEpic will auto-save for the first action in the stream, return the error, and will not auto-save for any subsequent actions thereafter. updateFretboard does not currently flow through any of my reducers (it is only responsible for passing new state to Firebase), although I may choose in the future to receive a promise to know when the save occurred and pass it through my reducers.

I am new to RxJS/redux-observable, so I suspect there is a better way of doing this. Thoughts?

Upvotes: 0

Views: 1271

Answers (2)

jayphelps
jayphelps

Reputation: 15401

When using redux-observable without any other side effect middleware (like redux-thunk) means all actions you dispatch must be plain old JavaScript objects--that includes anything your epics emits.

It's not clear what updateFretboard() returns, except that is probably isn't a POJO action; it's whatever fretboards.child(firebaseKey).update(fretboardData) returns.

If instead of emitting an action, you actually meant to just perform that as a side effect but ignore its return value entirely, you would use something like the do() operator, which is used to make a side effect without actually modifying the next'd values. You could then combine that with the ignoreElements() operator to prevent your epic from emitting anything ever.

export const updateFretboardEpic = (action$, store) => {
  return action$.ofType(
      ADD_STRING,
      REMOVE_STRING,  
      INC_STR_PITCH,
      DEC_STR_PITCH,
      ADD_FRET,
      REMOVE_FRET
    )
    .filter(() => store.getState().fretboard.firebaseKey !== undefined)
    .debounceTime(1000)
    .do(() => updateFretboard(
      store.getState().fretboard.fretboardData,
      store.getState().fretboard.firebaseKey
    ))
    .ignoreElements();
};

Keep in mind that by using ignoreElements() this particular epic will never emit anything (though it will still propagate errors/complete). It becomes basically "readonly".

If you didn't use ignoreElements(), your epic would actually re-emit the same action it matched, causing unwanted recursion.


You might also find it easier to invert control of what gets saved and what doesn't. Instead of having to maintain a list of actions that should trigger an autosave, you could instead have actions have some sort of property that your epic listens for to know to save.

e.g.

// Listens for actions with `autosave: true`
export const updateFretboardEpic = (action$, store) => {
  return action$.filter(action => action.autosave)
    // etc...
};

// e.g.
store.dispatch({
  type: ADD_STRING,
  autosave: true
});

Adjust convention to your apps needs, accordingly.

Upvotes: 3

Wilfried Barth
Wilfried Barth

Reputation: 13

I found a solution to my problem (thanks Jay for pointing me in the right direction!).

In my question, I wasn't explicit about what fretboards.child(firebaseKey).update(fretboardData) actually is. fretboards.child(firebaseKey) is a reference to the particular child of fretboards with the specified firebase key (the full path would be firebase.database().ref('fretboards').child(firebaseKey)). Firebase database utilizes a Promise-based API, so update will return a Promise:

const updateRequest=fretboards.child(firebaseKey).update(fretboardData)
  .then(function(result) {
    // do something with result
  }, function(err) {
    // handle error
  });

Borrowing from another answer Jay gave on a different post (Use fetch instead of ajax with redux-observable), I came up with the following solution:

import { fretboards } from '../firebase-config.js';
import Rx from 'rxjs/Rx'; 

import {
  ADD_STRING,
  REMOVE_STRING,
  INC_STR_PITCH,
  DEC_STR_PITCH,
  ADD_FRET,
  REMOVE_FRET,
  AUTOSAVE_FRETBOARD
} from '../actions/action-types';

function autoSaveFretboard(fretboardData, firebaseKey = undefined) {
  let autoSaveFretboardRequest = fretboards.child(firebaseKey)
    .update(fretboardData).then(function(result) {
      // won't need to pass result through reducers, so return
      // true to indicate save was successful
      return true;
    }, function(err) {
      // log error and return false in case of autosave failure
      console.log(err);
      return false;
    });
  return Rx.Observable.from(autoSaveFretboardRequest);
}

export const autoSaveFretboardEpic = (action$, store) => {
  return action$.ofType(
      ADD_STRING,
      REMOVE_STRING,  
      INC_STR_PITCH,
      DEC_STR_PITCH,
      ADD_FRET,
      REMOVE_FRET
    )
    .filter(() => store.getState().fretboard.firebaseKey !== undefined)
    .debounceTime(750)
    .mergeMap(() => autoSaveFretboard(
        store.getState().fretboard.fretboardData,
        store.getState().fretboard.firebaseKey
      ).map((res) => (
        { type: AUTOSAVE_FRETBOARD, success: res }
      ))
    );
};

Based on whether the auto-save promise is successful or not, a boolean will be returned to indicate the success of the save, which is mapped to a new action AUTOSAVE_FRETBOARD which I can pass along to my reducers.

As Jay pointed out in that prior post, promises can't be cancelled. Since the Firebase database is implemented as a Promise-based API, I don't really see a way around that. At the moment, I can't think of a reason why I would want to cancel the auto-save, so I'm happy with this solution!

Upvotes: 1

Related Questions