Home » Uncategorized » Migrating my first AngularJS app to Redux+AngularJS

Migrating my first AngularJS app to Redux+AngularJS

From my experience with AngularJS, one of the biggest problem in AngularJS is manage state and flow of data. AngularJS provides many solutions to transfer data between directive to directive or from controller to directive but none of them can solve this problem perfectly. When your app grows, you will find it hard to add a simple features because you can’t manage your data easily. Your data has been scattered in many directives and services.

AngularJS redux meme

That’s main problem when I started working with the project 1ClickTrips. Although the app might looks simple, but under the hood, there are many features and the large amount of data from search request that will need to be manipulated carefully.

My approach when working on any AngularJS is: break the UI to many directives and keep my code tidy and easier to manage. Although I tried to avoid including too many logic in a directive. The directive still end up with a lot of code to manage application logic. It happens because there are no better way to manage application logic in AngularJS.

In Redux, you can’t modify application state/data directly. Your data comes from Redux store and you can’t modify it. When you want to change anything. You have to call an actions. For example, call start searching action, call update search result action… You can call multiple actions in a same time. Your actions will be received by reducers and reducers will update application state. Your updated applicate will come to Redux store and Redux store broadcasts data to its listeners – your directives.

This is a diagram to demonstrate the flow in Redux:

redux flow

There are many advantage in AngularJS + Redux app:

  • You find that your directives are tidier, most of your the code in your directives has been gone.
  • You don’t spend too much time to write a unit test for your directives.
  • You don’t actually need a service anymore.
  • All of the code to manage application logic is stored in action and reducer file. It’s easier to write test for these files too.
  • You don’t depend on AngularJS anymore. When you don’t like AngularJS, you can bring your action and reducer files to React and they still works.
  • When you found a bug, it’s easier to check which action creates bug.

Here is an example directive before and after using Redux:

Before using Redux:

/// <reference path="../../reference.ts" />

import {ItineraryResult, Appointment, TripData} from '../../common/interfaces';
import {ItineraryHelper} from '../../trip/itinerary-helper.service';
import {AppointmentListHelper} from './appointment-list.helper';
import {AppointmentDialogHelper} from '../appointment-dialog/appointment-dialog.helper';
import {WaitingAnimationService} from '../../common/waiting-animation.service';
import {SearchService} from '../../trip/search.service';
import {ItineraryService} from '../../trip/itinerary.service';
import {BootboxService} from '../../common/bootbox/bootbox.service';

export class AppointmentListController {

  public static initFn: ng.IDirectiveFactory = () => {
    return {
      template: require('./appointment-list.html'),
      controller: AppointmentListController,
      controllerAs: 'appointmentList',
      bindToController: {
        itineraries: '=',
        selectedItinerary: '=',
        onItineraryUpdated: '='
      }
    }
  }

  private selectedItinerary: ItineraryResult;

  private onUpdateAppointment: Function;

  private itineraries: ItineraryResult[];

  private editingAppointment: Appointment;

  private editingItinerary: ItineraryResult

  private onItineraryUpdated: Function;

  constructor(private itineraryHelper: ItineraryHelper,
              private appointmentListHelper: AppointmentListHelper,
              private appointmentDialogHelper: AppointmentDialogHelper,
              private waitingAnimationService: WaitingAnimationService,
              private $timeout: ng.ITimeoutService,
              private searchService: SearchService,
              private itineraryService: ItineraryService,
              private bootboxService: BootboxService) {
    this.onSaveUpdatedAppointment = this.onSaveUpdatedAppointment.bind(this);
    this.onDeleteAppointment = this.onDeleteAppointment.bind(this);
    this.onDiscardAppointment = this.onDiscardAppointment.bind(this);
    this.onSaveNewAppointment = this.onSaveNewAppointment.bind(this);
  }

  getAppointmentTitle(itinerary: ItineraryResult) {
    return itinerary.appointment.name;
  }

  clickAppointment(itinerary: ItineraryResult) {
    this.appointmentListHelper.clickAppointment(itinerary);
  }

  displayAppointmentTime(itinerary) {
    return this.itineraryHelper.displayAppointmentTime(itinerary.appointment);
  }

  isAppointmentActive(itinerary: ItineraryResult) {
    return itinerary === this.selectedItinerary;
  }

  isAppointmentDone(itinerary: ItineraryResult) {
    return itinerary.isItineraryDone && itinerary !== this.selectedItinerary;
  }

  editAppointment(itinerary: ItineraryResult) {
    let index = this.itineraries.indexOf(itinerary);
    let appointment: Appointment = itinerary.appointment;
    this.editingItinerary = itinerary;
    this.appointmentDialogHelper.openDialog({
      appointment: appointment,
      onDelete: this.onDeleteAppointment,
      onSave: this.onSaveUpdatedAppointment,
      onDiscard: this.onDiscardAppointment,
      appointmentCount: this.itineraries.length,
      appointmentIndex: index
    });
  }

  showAddAppointmentDialog() {
    let lastItinerary = this.getLastNonReturnItinerary();
    let startDate = moment(lastItinerary.appointment.startDate).add(1, 'days').toDate();
    let endDate = moment(startDate).add(2, 'hours').toDate();
    let appointment = {
      startDate: startDate,
      endDate: endDate,
      origin: _.cloneDeep(lastItinerary.appointment.destination)
    }
    this.appointmentDialogHelper.openDialog({
      appointment: appointment,
      onSave: this.onSaveNewAppointment,
      onDiscard: this.onDiscardAppointment,
      appointmentCount: this.itineraries.length,
      appointmentIndex: this.itineraries.length - 1
    });
  }

  onDeleteAppointment(appointment: Appointment) {
    this.waitingAnimationService.showWaitingDialog('Updating appointments...', 2000);
    let index = this.itineraries.indexOf(this.editingItinerary);
    if (index >= 0) this.itineraries.splice(index, 1);
    this.itineraries = this.itineraryService.insertNoAppointmentItinerary(this.itineraries);
    this.selectedItinerary = this.itineraryService.findFirstUnplannedItinerary(this.itineraries);
  }

  onSaveUpdatedAppointment(appointment: Appointment) {
    this.editingItinerary.appointment = _.cloneDeep(appointment);
  }

  fetchAndRefreshItineraries(itineraries: ItineraryResult[]) {
    return this.itineraryService.insertNoAppointmentItinerary(itineraries);
  }

  onDiscardAppointment(appointment: Appointment) {
    this.editingItinerary = null;
  }

  onSaveNewAppointment(appointment: Appointment) {
    this.waitingAnimationService.showWaitingDialog('Add new appointment...');
    this.itineraryService
      .addAppointmentAndUpdateItineraries(this.itineraries, appointment)
      .then((itineraries: ItineraryResult[]) => {
        this.itineraries = itineraries;
        this.selectedItinerary = this.itineraryService.findFirstUnplannedItinerary(this.itineraries);
        this.waitingAnimationService.hideWaitingDialog();
      });

  }

  private getLastNonReturnItinerary() {
    let result = this.itineraries[0];
    this.itineraries.forEach(itinerary => {
      if (itinerary.appointment.startDate.getTime() > result.appointment.startDate.getTime() && !itinerary.isReturnItinerary) {
        result = itinerary;
      }
    })
    return result;
  }

}

After using Redux:

/// <reference path="../../reference.ts" />
import {
  ItineraryResult,
  Appointment,
  TripData
} from '../../common/interfaces'
import { ItineraryHelper } from '../../trip/itinerary-helper.service'
import { setActiveItinerary } from '../../redux/actions/itinerary/active-itinerary'
import { editItinerary } from '../../redux/actions/appointment-dialog'

export class AppointmentListController {

  public static initFn: ng.IDirectiveFactory = () => {
    return {
      template: require('./appointment-list.html'),
      controller: AppointmentListController,
      controllerAs: 'appointmentList'
    };
  };

  /**
   * Selected itinerary
   *
   * @private
   * @type {ItineraryResult}
   * @memberOf AppointmentListController
   */
  private selectedItinerary: ItineraryResult;

  /**
   * Array of all itineraries
   *
   * @private
   * @type {ItineraryResult[]}
   * @memberOf AppointmentListController
   */
  private itineraries: ItineraryResult[];

  /**
   * First itinerary from itineraries
   *
   * @private
   * @type {ItineraryResult}
   * @memberOf AppointmentListController
   */
  private firstItinerary: ItineraryResult;

  /**
   * Method to set active itinerary
   *
   * @private
   * @type {Function}
   * @memberOf AppointmentListController
   */
  private setActiveItinerary: Function;

  constructor(private itineraryHelper: ItineraryHelper,
              private $ngRedux,
              private $scope) {
    let unsubscribeRedux = this.$ngRedux.connect(this.mapStateToThis, this.mapDispatchToThis)(this);
    $scope.$on('$destroy', unsubscribeRedux);
  }

  mapStateToThis(state) {
    return {
      itineraries: state.itinerary.itineraries,
      selectedItinerary: state.itinerary.itineraries.find(item => item.id === state.itinerary.activeItinerary.id),
      firstItinerary: _.first(state.itinerary.itineraries)
    }
  }

  mapDispatchToThis(dispatch) {
    return {
      setActiveItinerary: (itineraryId) => dispatch(setActiveItinerary(itineraryId)),
      editItinerary: (itineraryId) => dispatch(editItinerary(itineraryId))
    }
  }

  getAppointmentTitle(itinerary: ItineraryResult) {
    if (itinerary.isNoAppointment) return 'No appointment';
    if (itinerary.isReturnItinerary) return 'Return to ' + this.firstItinerary.appointment.origin.address;
    if (itinerary.appointment.isReturnAppointment) return 'Return to ' + this.firstItinerary.appointment.origin.address;
    return itinerary.appointment.name;
  }

  displayAppointmentTime(itinerary) {
    return this.itineraryHelper.displayAppointmentTime(itinerary.appointment);
  }

  isAppointmentActive(itinerary: ItineraryResult) {
    return this.selectedItinerary && itinerary === this.selectedItinerary;
  }

  isAppointmentDone(itinerary: ItineraryResult) {
    return this.selectedItinerary && itinerary.isItineraryDone && itinerary !== this.selectedItinerary;
  }

  checkAndSetActiveItinerary(itinerary: ItineraryResult) {
    if (itinerary.isItineraryDone) {
      this.setActiveItinerary(itinerary.id);
    }
  }

  getAppointmentClasses(itinerary: ItineraryResult) {
    return {
      active: this.isAppointmentActive(itinerary),
      done: this.isAppointmentDone(itinerary),
      return: itinerary.isReturnItinerary || itinerary.appointment.isReturnAppointment,
      question: itinerary.isNoAppointment
    }
  }
}

What is the different between those pieces of code ? Right, you can’t change your data directly anymore because your data comes from Redux, you have to dispatch actions to change your data. A lot of services have been omitted too.

Please notice that my example is not perfect, because best practice from Dan Abramov suggests that we have to split component in to smart and dump components.