jQuery ♥️ Redux

Adapting old school jQuery with Redux

Learned skills

  • Swift / Objective-C -
  • iOS Development, RxSwift​
  • JavaScript - ReactNative, React.js, Nodejs, Redux, RxJS​
  • Ruby - Ruby On Rails

Calvin Huang

Full Stack Engineer

FSM

Flying Spaghetti Monster

Spaghetti code is a pejorative phrase for source code that has a complex and tangled control structure, especially one using many GOTO statements, exceptions, threads, or other "unstructured" branching constructs. It is named such because program flow is conceptually like a bowl of spaghetti, i.e. twisted and tangled.

Template based GitHub bookshelf implemented by jQuery

Here is the code. Quite pretty, right?

var repos = [];
var bookmarks = [];
var page = 1;

const repoList = $
  .templates(
    'repo-list',
    { markup: '#repo-list', templates: { repo: $.templates('#repo') } },
  );

function fetchPublicRepos(page = 1) {
  $.getJSON(`https://api.github.com/search/repositories?q=language:javascript&per_page=10&page=${page}`)
    .done((data) => {
      const bookmarkIds = bookmarks.map(bookmark => bookmark.repo_id);

      repos = repos.concat(data.items.map((repo) => Object.assign(repo, { is_saved: bookmarkIds.includes(repo.id) })));

      $('#app').html(repoList.render({ repos: repos }));
    });
}

function fetchBookmarks() {
  $.getJSON('/api/v1/bookmarks')
    .done((data) => {
      bookmarks = data;
      const bookmarkIds = bookmarks.map(bookmark => bookmark.repo_id);

      repos.map((repo) => Object.assign(repo, { is_saved: bookmarkIds.includes(repo.id) }));

      $('#app').html(repoList.render({ repos: repos }));
    });
}

function intialApp() {
  fetchPublicRepos(page++);
  fetchBookmarks();
}

intialApp();

$(document).on('click', '.more-repo-btn', () => {
  fetchPublicRepos(page++);
});

not the worst

function toggleBookmark(repoId) {
  const selectedRepo = repos.find(repo => repo.id === repoId);
  selectedRepo.is_saved = !selectedRepo.is_saved;

  $('#app').html(repoList.render({ repos: repos }));

  if (selectedRepo.is_saved) {
    const { full_name } = selectedRepo;
    createBookmark(repoId, full_name);
  } else {
    deleteBookmark(repoId);
  }
}

function createBookmark(repoId, fullName) {
  $.ajax({
    method: 'POST',
    contentType: 'application/json',
    url: '/api/v1/bookmarks',
    data: JSON.stringify({ repo_id: repoId, full_name: fullName }),
    error: (jqXHR, textStatus, error) => {
      showNotification(`${error}: ${jqXHR.responseJSON.message}`);

      const selectedRepo = repos.find(repo => repo.id === repoId);
      selectedRepo.is_saved = !selectedRepo.is_saved;

      $('#app').html(repoList.render({ repos: repos }));
    },
  });
}

function deleteBookmark(repoId) {
  $.ajax({
    method: 'DELETE',
    contentType: 'json',
    url: `/api/v1/bookmarks/${repoId}`,
    error: (jqXHR, textStatus, error) => {
      showNotification(error);

      const selectedRepo = repos.find(repo => repo.id === repoId);
      selectedRepo.is_saved = selectedRepo.is_saved;

      $('#app').html(repoList.render({ repos: repos }));
    },
  });
}

It's not too bad, huh?

So......how can we test it?

redux

It's time to Redux

what is redux

Redux is a predictable state container for JavaScript apps. It helps you write applications that behave consistently, run in different environments (client, server, and native), and are easy to test. (From redux official site.)

And they have the VERY HIGH quality documentation!!!

We all comes from MVC/MVVM.

So...what's different between MVC/MVVM <= >Redux/Flux?

By the way, Redux is inspired by Flux.

redux flow

STATE

ACION

REDUCER

STORE

state tree

repos: []

repo: {}

repo: {}

repo: {}

{
    "id": 10270250,
    "name": "react",
    "full_name": "facebook/react",
    "owner": {
        "login": "facebook",
        "id": 69631,
        "avatar_url": "https://avatars3.githubusercontent.com/u/69631?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/facebook",
        "html_url": "https://github.com/facebook",
        "followers_url": "https://api.github.com/users/facebook/followers",
        "following_url": "https://api.github.com/users/facebook/following{/other_user}",
        "gists_url": "https://api.github.com/users/facebook/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/facebook/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/facebook/subscriptions",
        "organizations_url": "https://api.github.com/users/facebook/orgs",
        "repos_url": "https://api.github.com/users/facebook/repos",
        "events_url": "https://api.github.com/users/facebook/events{/privacy}",
        "received_events_url": "https://api.github.com/users/facebook/received_events",
        "type": "Organization",
        "site_admin": false
    },
    "private": false,
    "html_url": "https://github.com/facebook/react",
    "description": "A declarative, efficient, and flexible JavaScript library for building user interfaces.",
    "fork": false,
    "url": "https://api.github.com/repos/facebook/react",
    "forks_url": "https://api.github.com/repos/facebook/react/forks",
    "keys_url": "https://api.github.com/repos/facebook/react/keys{/key_id}",
    "collaborators_url": "https://api.github.com/repos/facebook/react/collaborators{/collaborator}",
    "teams_url": "https://api.github.com/repos/facebook/react/teams",
    "hooks_url": "https://api.github.com/repos/facebook/react/hooks",
    "issue_events_url": "https://api.github.com/repos/facebook/react/issues/events{/number}",
    "events_url": "https://api.github.com/repos/facebook/react/events",
    "assignees_url": "https://api.github.com/repos/facebook/react/assignees{/user}",
    "branches_url": "https://api.github.com/repos/facebook/react/branches{/branch}",
    "tags_url": "https://api.github.com/repos/facebook/react/tags",
    "blobs_url": "https://api.github.com/repos/facebook/react/git/blobs{/sha}",
    "git_tags_url": "https://api.github.com/repos/facebook/react/git/tags{/sha}",
    "git_refs_url": "https://api.github.com/repos/facebook/react/git/refs{/sha}",
    "trees_url": "https://api.github.com/repos/facebook/react/git/trees{/sha}",
    "statuses_url": "https://api.github.com/repos/facebook/react/statuses/{sha}",
    "languages_url": "https://api.github.com/repos/facebook/react/languages",
    "stargazers_url": "https://api.github.com/repos/facebook/react/stargazers",
    "contributors_url": "https://api.github.com/repos/facebook/react/contributors",
    "subscribers_url": "https://api.github.com/repos/facebook/react/subscribers",
    "subscription_url": "https://api.github.com/repos/facebook/react/subscription",
    "commits_url": "https://api.github.com/repos/facebook/react/commits{/sha}",
    "git_commits_url": "https://api.github.com/repos/facebook/react/git/commits{/sha}",
    "comments_url": "https://api.github.com/repos/facebook/react/comments{/number}",
    "issue_comment_url": "https://api.github.com/repos/facebook/react/issues/comments{/number}",
    "contents_url": "https://api.github.com/repos/facebook/react/contents/{+path}",
    "compare_url": "https://api.github.com/repos/facebook/react/compare/{base}...{head}",
    "merges_url": "https://api.github.com/repos/facebook/react/merges",
    "archive_url": "https://api.github.com/repos/facebook/react/{archive_format}{/ref}",
    "downloads_url": "https://api.github.com/repos/facebook/react/downloads",
    "issues_url": "https://api.github.com/repos/facebook/react/issues{/number}",
    "pulls_url": "https://api.github.com/repos/facebook/react/pulls{/number}",
    "milestones_url": "https://api.github.com/repos/facebook/react/milestones{/number}",
    "notifications_url": "https://api.github.com/repos/facebook/react/notifications{?since,all,participating}",
    "labels_url": "https://api.github.com/repos/facebook/react/labels{/name}",
    "releases_url": "https://api.github.com/repos/facebook/react/releases{/id}",
    "deployments_url": "https://api.github.com/repos/facebook/react/deployments",
    "created_at": "2013-05-24T16:15:54Z",
    "updated_at": "2017-09-04T17:35:35Z",
    "pushed_at": "2017-09-03T17:05:04Z",
    "git_url": "git://github.com/facebook/react.git",
    "ssh_url": "git@github.com:facebook/react.git",
    "clone_url": "https://github.com/facebook/react.git",
    "svn_url": "https://github.com/facebook/react",
    "homepage": "https://facebook.github.io/react/",
    "size": 130749,
    "stargazers_count": 74879,
    "watchers_count": 74879,
    "language": "JavaScript",
    "has_issues": true,
    "has_projects": true,
    "has_downloads": true,
    "has_wiki": true,
    "has_pages": true,
    "forks_count": 14117,
    "mirror_url": null,
    "open_issues_count": 759,
    "forks": 14117,
    "open_issues": 759,
    "watchers": 74879,
    "default_branch": "master",
    "score": 1
}

action

const [ACTION_NAME] = 'WHAT_IS_YOUR_ACTION_NAME';

The basic action structure.

{
    type: [ACTION_NAME],
    [YOUR_PAYLOAD_NAME]: [YOUR_PAYLOAD],
    .....
    [OTHER_PAYLOAD_NAME]: [YOUR_PAYLOAD],
}

action creator

Simply return a action.

function actionName(yourPayload, ....) {
    return {
        type: [ACTION_NAME],
        yourPayload,
        .....
    }
}
/**
 * Action types
 */
const FETCH_PUBLIC_REPOS = 'FETCH_PUBLIC_REPOS';
const RECEIVE_PUBLIC_REPOS = 'RECEIVE_FETCH_PUBLIC_REPOS';

/**
 * Action creators
 */
function fetchPublicRepos() {
  return {
    type: FETCH_PUBLIC_REPOS,
    page: page,
  };
}

function receivePublicRepos(repos) {
  return {
    type: FETCH_PUBLIC_REPOS,
    repos: repos,
  };
}

reconstruct our jq app

reducer

(previousState, action) => newState

Handling actions

const intialState = [];

function repos(state = initialState, action) {
    switch (action.type) {
        case RECEIVE_PUBLIC_REPOS:
            return state.concat(action.repos);

        default: return initialState;
    }
}

store

// When CommonJS module.exports, exports or ES6Module export not exists,
// Redux will register in global.Redux.
const store = Redux.createStore({
    reducers,
});

store.subscribe(() => {
    const state = store.getState();
});

add redux-devtool middleware

To boost up your development speed.

// When CommonJS module.exports, exports or ES6Module export not exists,
// Redux will register in global.Redux.
const store = Redux.createStore({
    reducers,
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
});

store.subscribe(() => {
    const state = store.getState();
});

devtool we just added

SO, here we go

Our jQuery app will reborn with Redux!!!!

/**
 * Action creators
 */
function fetchPublicRepos() {
  return {
    type: FETCH_PUBLIC_REPOS,
  };
}

function receivePublicRepos(repos) {
  return {
    type: RECEIVE_PUBLIC_REPOS,
    repos: repos,
  };
}

function fetchReposNextPage() {
  return {
    type: FETCH_REPOS_NEXT_PAGE,
  }
}

function setReposAreSaved(bookmarkIds) {
  return {
    type: SET_REPOS_ARE_SAVED,
    bookmarkIds: bookmarkIds,
  }
}

function fetchBookmarks() {
  return {
    type: FETCH_BOOKMARKS,
  }
}

function receiveBookmarks(bookmarks) {
  return {
    type: RECEIVE_BOOKMARKS,
    bookmarks: bookmarks,
  }
}

function toggleBookmark(repoId) {
  return {
    type: TOGGLE_BOOKMARK,
    repoId: repoId,
  };
}

function createBookmark(repoId, fullName) {
  return {
    type: CREATE_BOOKMARK,
    repoId: repoId,
    fullName: fullName,
  };
}

function bookmarkCreated(repoId, fullName) {
  return {
    type: BOOKMARK_CREATED,
    repoId: repoId,
    fullName: fullName,
  };
}

function deleteBookmark(repoId) {
  return {
    type: DELETE_BOOKMARK,
    repoId: repoId,
  };
}

function bookmarkDeleted(repoId) {
  return {
    type: BOOKMARK_DELETED,
    repoId: repoId,
  };
}

function showNotification(message) {
  return {
    type: SHOW_NOTIFICATION,
    message: message,
  }
}
/**
 * Reducers
 */
function repos(state = { page: 1, data: [] }, action) {
  switch (action.type) {
    case FETCH_REPOS_NEXT_PAGE:
      return Object.assign(state, { page: state.page + 1 });

    case RECEIVE_PUBLIC_REPOS:
      return Object.assign(state, { data: state.data.concat(action.repos) });

    case SET_REPOS_ARE_SAVED:
      return Object.assign(
        state,
        { data: state.data.map(repo => (
          Object.assign(repo, { is_saved: action.bookmarkIds.includes(repo.id) })
        ))}
      );
    
    default:
      return state;
  }
}

function bookmarks(state = [], action) {
  switch (action.type) {
    case RECEIVE_BOOKMARKS:
      return state.concat(action.bookmarks);

    case BOOKMARK_CREATED:
      return state.concat({ repo_id: action.repoId, full_name: action.fullName });

    case BOOKMARK_DELETED:
      return state.filter(bookmark => bookmark.repo_id !== action.repoId);

    default:
      return state;
  }
}
    ...
    case CREATE_BOOKMARK: {
      store.dispatch(bookmarkCreated(action.repoId, action.fullName));

      $.ajax({
        method: 'POST',
        contentType: 'application/json',
        url: '/api/v1/bookmarks',
        data: JSON.stringify({ repo_id: action.repoId, full_name: action.fullName }),
        error: (jqXHR, textStatus, error) => {
          store.dispatch(showNotification(`${error}: ${jqXHR.responseJSON.message}`));
          store.dispatch(bookmarkDeleted(action.repoId));
        },
      });

      break;
    }

    case DELETE_BOOKMARK: {
      store.dispatch(bookmarkDeleted(action.repoId));

      $.ajax({
        method: 'DELETE',
        contentType: 'json',
        url: `/api/v1/bookmarks/${action.repoId}`,
        error: (jqXHR, textStatus, error) => {
          const repo = state.repos.data.find(repo => repo.id === action.repoId);

          store.dispatch(showNotification(error));
          store.dispatch(bookmarkCreated(repo.id, repo.full_name));
        },
      });

      break;
    }

    case BOOKMARK_CREATED:
    case BOOKMARK_DELETED: {
      store.dispatch(setReposAreSaved(state.bookmarks.map(bookmark => bookmark.repo_id)));

      break;
    }

    case SET_REPOS_ARE_SAVED: {
      $('#app').html(repoList.render({ repos: state.repos.data }));

      break;
    }

    case SHOW_NOTIFICATION: {
      const modal = $('#notification');
      modal.find('.message').text(action.message);
      modal.modal({ show: true });

      break;
    }
  }
});
store.subscribe(() => {
  const state = store.getState();
  const action = state.lastAction;

  switch (action.type) {
    case FETCH_PUBLIC_REPOS: {
      $.getJSON(`https://api.github.com/search/repositories?q=language:javascript&per_page=10&page=${action.page}`)
        .done((data) => {
          store.dispatch(receivePublicRepos(data.items));
        });

      break;
    }

    case FETCH_REPOS_NEXT_PAGE: {
      store.dispatch(fetchPublicRepos());

      break;
    }

    case FETCH_BOOKMARKS: {
      $.getJSON('/api/v1/bookmarks')
        .done((data) => {
          store.dispatch(receiveBookmarks(data));
        });

      break;
    }

    case RECEIVE_PUBLIC_REPOS:
    case RECEIVE_BOOKMARKS: {
      store.dispatch(setReposAreSaved(state.bookmarks.map(bookmark => bookmark.repo_id)));

      break;
    }

    case TOGGLE_BOOKMARK: {
      if (state.bookmarks.find(bookmark => bookmark.repo_id === action.repoId)) {
        store.dispatch(deleteBookmark(action.repoId));

      } else {
        const repo = state.repos.data.find(repo => repo.id === action.repoId);
        store.dispatch(createBookmark(repo.id, repo.full_name));
      }

      break;
    }
    ...
function intialApp() {
  store.dispatch(fetchPublicRepos());
  store.dispatch(fetchBookmarks());
}

intialApp();

$(document).on('click', '.more-repo-btn', () => {
  store.dispatch(fetchReposNextPage());
});

$(document).on('click', '.bookmark', (e) => {
  e.preventDefault();

  const repoId = parseInt($(e.currentTarget).data('repo-id'), 10);
  store.dispatch(toggleBookmark(repoId));
});
Didn't manipulate/hold state.
Put UI updating code together(DRY).

but!!!

it still can't be tested!

split app into modules

And wrap it up as ESModule for jest

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
    typeof define === 'function' && define.amd ? define(['exports'], factory) :
      (factory((global.actions = global.actions || {})));
}(this, (function (exports) {
  'use strict';

  /**
   * Action types
   */
  const actionTypes = {
    FETCH_PUBLIC_REPOS: 'FETCH_PUBLIC_REPOS',
    RECEIVE_PUBLIC_REPOS: 'RECEIVE_PUBLIC_REPOS',

    FETCH_REPOS_NEXT_PAGE: 'FETCH_REPOS_NEXT_PAGE',

    ....
  }

  /**
   * Action creators
   */
  const actions = {
    fetchPublicRepos: () => ({
      type: actionTypes.FETCH_PUBLIC_REPOS,
    }),

    ....

    showNotification: (message) => ({
      type: actionTypes.SHOW_NOTIFICATION,
      message: message,
    }),
  }

  exports.types = actionTypes;
  exports.default = Object.assign(actions, { types: actionTypes });

  Object.defineProperty(exports, '__esModule', { value: true });

})));
(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
    typeof define === 'function' && define.amd ? define(['exports'], factory) :
      (factory((global.middleware = global.middleware || {})));
}(this, (function (exports) {
  'use strict';

  /**
   * Middleware
   */
  const middleware = (actionTypes, actions, $) => {
    return store => next => action => {
      const returnValue = next(action);
      const state = store.getState();

      switch (action.type) {
        case actionTypes.FETCH_PUBLIC_REPOS: {
          $.getJSON(`https://api.github.com/search/repositories?q=language:javascript&per_page=10&page=${action.page}`)
            .done((data) => {
              store.dispatch(actions.receivePublicRepos(data.items));
            });

          break;
        }

        case actionTypes.FETCH_REPOS_NEXT_PAGE: {
          store.dispatch(actions.fetchPublicRepos());

          break;
        }

        ....
    }
  }

  exports.default = middleware;

  Object.defineProperty(exports, '__esModule', { value: true });

})));
Use middleware to handle asynchronous actions instead.
And DI is important!

split app into modules

<script src="/js/actions.js"></script>
<script src="/js/reducers.js"></script>
<script src="/js/middleware.js"></script>
<script>
  const store = Redux.createStore(
    reducers(actions.types, Redux),
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
    Redux.applyMiddleware(
      middleware.default(actions.types, actions.default, $),
    ),
  );

  ....
</script>

And wrap it up as ESModule for jest

Then we can test it!

import configureMockStore from 'redux-mock-store';

import middleware from './middleware';
import actions, { types } from './actions';

describe('test middleware behavior', () => {
  // Mock jQuery.
  class jQuery {
    constructor(selector, context) { }
    html() { }
    find() { return this; }
    modal() { return this; }
    text() {}
  }

  describe('when the network request is fine', () => {
    const $ = (selector, context) => new jQuery(selector, context);
    $.templates = () => ({ render: () => { } });
    $.ajax = () => { };
    const mockStore = configureMockStore([middleware(types, actions, $)]);

    it('createBookmark action should triggers bookmarkCreated and setReposAreSaved action', () => {
      const store = mockStore({
        repos: { page: 1, data: [{ id: 1, full_name: 'foo/boo' }] },
        bookmarks: [],
      });

      const expectedActions = [
        actions.createBookmark(1, 'foo/boo'),
        actions.bookmarkCreated(1, 'foo/boo'),

        // There is no reducer included, so bookmarks array stil is empty.
        actions.setReposAreSaved([]),
      ];
      store.dispatch(expectedActions[0]);

      expect(store.getActions()).toEqual(expectedActions);
    });
    
    ...
    ...

wrap it up

jQuery ♥️ Redux

However...perhaps we can try React.js

jq-with-redux

jQuery with Redux

By Calvin Huang

jQuery with Redux

jQuery ♥️ Redux = Good bye Flying Spaghetti Monster

  • 1,934