Writing elegant JavaScript

with Functional Reactive Programming

Jimbo Jones

263 6th St., Suite 206

Springfield, NT 01646

Hours: 8 AM - 5 PM

Tel: 800-267-8634, ext 82

 

Seymore Skinner

263 6th St., Suite 206

Springfield, NT 01646

Hours: 11 AM - 7 PM

Tel: 800-267-8634, ext 59

 

Carl Carlton

2354 N. President St.

Springfield, NT 01649

Hours: 9 AM - 6 PM

Tel: 877-236-0224

For every visitor to our site, display a list of five sales agents. Only show agents with open office hours. Of those, show the ones that are closest to the visitor's location first. For sales agents that share an office, list them in random order so they get equal chances for commissions.

Algorithm:

  • Create an empty Array A. 
    
  • Loop through the reps.
    • If a rep is not currently available, go to the next one.
    • Calculate the distance between the customer and rep's locations.
    • Store the rep with that distance in the array along with a random number.
  • When finished looping, sort the array by
    distance & the random number.
  • Create a new Array B.
  • Loop 5 times, with index i.
    • For the rep at index i of A, place the contact information into B.
  • Return B.
function getSalesContacts(allReps, max, location) {
  var available = [], rep;
  for (var i = 0; i < allReps.length; i++) {
    rep = allReps[i];
    if (!rep.isAvailable()) {
      continue;
    }
    available.push({
      rep: rep,
      dist: getDistance(location, rep.location),
      rand: Math.random()
    });
  }
  available.sort(function (a, b) {
    return (a.dist - b.dist) || (a.rand - b.rand);
  });
  var results = [];
  for (i = 0; i < Math.min(max, available.length); i++) {
    rep = available[i].rep;
    results.push({
      name: rep.name,
      phone: rep.phone,
      email: rep.email,
      location: rep.location,
      hours: rep.hours
    });
  }

  return results;
}

For every visitor to our site, display the contact information of five sales agents. Only show agents with open office hours. Of those, show the ones that are closest to the visitor's location first. For sales agents that share an office, list them in random order so they get equal chances for commissions.

Filter

For every visitor to our site, display the contact information of five sales agents. Only show agents with open office hours. Of those, show the ones that are closest to the visitor's location first. For sales agents that share an office, list them in random order so they get equal chances for commissions.

Map

For every visitor to our site, display the contact information of five sales agents. Only show agents with open office hours. Of those, show the ones that are closest to the visitor's location first. For sales agents that share an office, list them in random order so they get equal chances for commissions.

Sort

For every visitor to our site, display the contact information of five sales agents. Only show agents with open office hours. Of those, show the ones that are closest to the visitor's location first. For sales agents that share an office, list them in random order so they get equal chances for commissions.

Slice

function getSalesContacts(allReps, max, location) {
  return allReps
    .filter(rep => rep.isAvailable())
    .map(rep => ({
      rep: rep,
      dist: getDistance(location, rep.location),
      rand: Math.random()
    }))
    .sort((a, b) => (a.dist - b.dist) || (a.rand - b.rand))
    .slice(0, max)
    .map(d => {
      rep = d.rep;
      return {
        name: rep.name,
        phone: rep.phone,
        email: rep.email,
        location: rep.location,
        hours: rep.hours
      }
    });
}
function getSalesContacts(allReps, max, location) {
  return _(allReps)
    .filter(rep => rep.isAvailable())
    .map(rep => ({
      rep: rep,
      dist: getDistance(location, rep.location),
      rand: Math.random()
    }))
    .sortByAll(['dist', 'rand'])
    .take(max)
    .pluck('rep')
    .pick(['name', 'phone', 'email', 'location', 'hours']);
}

Elegant Code is...

  • concise

  • Clear

  • Consistent

Asynchronous Code:

JavaScript's Greatest Strength & Weakness

What it gets right

  • Non-blocking execution (besides alert, confirm, etc)

  • Single-threaded; uses event loop instead of parallel execution, so no need for locking primitives to protect mutable state.

  • Parallel code (Web Workers, etc) shares no mutable state; communication through message passing (Actor pattern)

  • Function expressions enable easy definition of callbacks.

What's  missing?

  • Consistency
  • Composibility

Asynchronous Functions:

Arbitrary Callbacks

$.ajax({
  url: 'http://some.url/',
  success: function (data, status, xhr) {
    // Handle success
  },
  error: function (xhr, status, err) {
    // Handle error
  }
});

Asynchronous Functions:

Node-style Callbacks

request.get('http://some.url/', function (err, result, body) {
  if (err) {
    // Handle error
  } else {
    // Handle result
  }
});

Asynchronous Functions:

Christmas Tree of Doom

function doDependentHttpRequests(cb) {
  request.get('http://some.url/a', function (err, result, body) {
    if (err) {
      return void cb(err);
    } 

    request.get('http://some.url/b/' + body.id, function (err, result, body) {
      if (err) {
        return void cb(err);
      }

      
      request.get('http://some.url/c/' + body.id, function (err, result, body) {
        if (err) {
          return void cb(err);
        }

        cb(null, result);
      });

    });

  });
}

Asynchronous Functions:

async helpers

function getLatestArticle(userId, cb) { 
  async.auto({
    user: function getUser (cb) {
      request.get(`http://some.api/users/${userId}`, cb);
    },
    latestBlogArticles: ['user', function (cb, {user}) {
      const blogTasks = user.blogIds.map(id => function (cb) {
        async.waterfall([
          function (cb) { request.get(`http://some.api/blogs/${id}`, cb); },
          function (blog, cb) { 
            const latestArticleId = blog.articleIds[blog.articleIds.length - 1];
            request.get(`http://some.api/articles/${latestArticleId}`, cb); 
          },
        ], cb);
      });
      async.parallelLimit(blogTasks, cb);
    }],
    latestArticle: ['latestBlogArticles', function (cb, {latestBlogArticles}) {
      const latest = latestBlogArticles.sort((a, b) => a.timestamp - b.timestamp)[0];
      cb(null, latest);
    }]
  }, function (err, {latestArticle}) {
    cb(err, latestArticle);
  });
}

Asynchronous Functions:

Promises

function getLatestArticle(userId) {
  return request.get(`http://some.api/users/${userId}`)
    .then(([user]) => {
      const latestArticlePromises = user.blogIds.map(id => 
        request.get(`http://some.api/blogs/${id}`)
          .then(blog => {
            const latestArticleId = blog.articleIds[blog.articleIds.length - 1];
            return request.get(`http://some.api/articles/${latestArticleId}`); 
          })
      );
      return Promise.all(latestArticlePromises);
    })
    .then(latestBlogArticles => {
      return latestBlogArticles.sort((a, b) => a.timestamp - b.timestamp)[0];
    });
}

Asynchronous Functions:

ES7 Async Functions!

// Still being standardized!

async function getLatestArticle(userId) {
  const user = await request.get(`http://some.api/users/${userId}`);
  const latestArticles = await* user.blogIds.map(id => 
    request.get(`http://some.api/blogs/${id}`)
      .then(blog => {
        const latestArticleId = blog.articleIds[blog.articleIds.length - 1];
        return request.get(`http://some.api/articles/${latestArticleId}`); 
      })
  );
  return latestArticles.sort((a, b) => a.timestamp - b.timestamp)[0];
}

Events:

Callback Properties

webSocket.onopen = function (e) {
};

webSocket.onmessage = function (e) {
};

webSocket.onerror = function (e) {
};

webSocket.onclose = function (e) {
};

Events:

Event Listeners

document
  .getElementById('the-button')
  .addEventListener('click', function (e) {
    // ...
  });

Events:

EventEmitter (streams, for example)

stream
  .on('data', function (chunk) {
    // Handle data
  })
  .on('error', function (err) { 
    // Handle error
  })
  .on('end', function () {
    // Handle end
  });

Events:

and time-based events

setTimeout(function () {
  // ...
}, 1000);

setInterval(function () {
  // ...
}, 1000);

requestAnimationFrame(function () {
  // ...
});

setImmediate(function () {
  // ...
});

And probably more!

These are all isomorphic!

Events: Moments in time + data

Async results = Moment in time + data

A node.js stream:

a sequence of (data) events over time

An EventEmitter:

Emits a sequence of events over time

An EventListener:

Receives a sequence of events over time

A setInterval handler:

Emits a sequence of events (callbacks) over time

A setTimeout handler:

Emits a sequence (limit 1) of events over time

A Promise:

Emits a sequence of (one result or error) events over time

Callbacks:

Emit a sequence of VaLUES over time

Iterables (arrays, generators, etc) are isomorphic!?:

provideS a sequence of VALUES over time (immediately)

Because these are isomorphic, they can all be adapted into one unified type.

Observables,

a.k.a. Streams,

a.k.a. signals

// Subscribe/observe

someStream
  .onValue(...)
  .onError(...)
  .onEnd(...);


// Unsubscribe/unobserve

someStream
  .offValue(...)
  .offError(...)
  .offEnd(...);

Reactive Programming:

Modeling your application as THE PROPAGATION OF CHANGE through DATA FLOWS (a.k.a. event streams!)

With everything adapted into a unified type, you can easily compose them with a single toolkit.

Functional Reactive Programming:

managing DATA flows (event streams) through functional programming techniques

Map

Time

circles.map(convertToTriangles)

flatMap

Time

items.flatMap(i => i.getDerivedStream())

Filter

Time

faces.filter(f => f.isHappy())

Scan

Time

0

7

7

12

19

1

20

-8

12

2

14

-5

9

numbers.scan((sum, n) => sum + n, 0)

Throttle

Time

noisyChannel.throttle(1000)

Debounce

Time

twitchySignal.debounce(200)

Zip

Time

stream.zip(requests, responses)

Merge

Time

stream.merge([likes, shares, retweets])

Combine

Time

stream.combine([profile, ranking, history])

GO2L.INK/frpgame

State Server

Player Agent

Player Agent

Player Agent

Red: 12 Blue: 56

Guidelines:

  • All data & events are streams! Stream all the things!
  • Make each stream as simple and single-purpose as possible so they can be easily composed.
  • Map out your flow of data.
  • Data flows must be uni-directional.
  • Flows cannot be circular; if an arrow is pointing up, something is probably wrong.
  • Something must pull data from a stream; make sure at the very bottom, something is consuming the data, or there will be no data flowing.

RxJs

HIGHLAND.js

Kefir

Flyd

const connections = kefir.repeat(
  () => kefir.stream(
    emitter => {
      setTimeout(() => {
        const socket = new WebSocketClient(url);
        socket.onopen = () => emitter.emit(socket);
        socket.onerror = err => emitter.error(err);
        socket.onclose = () => emitter.end();
      }, 1000);
    }
  )
  .endOnError()
);



const inboundMessages = connections
  .flatMapLatest(s => kefir.stream(
    emitter => s.onmessage = emitter.emit))
  .map(e => JSON.parse(e.data));
const stateMessages = inboundMessages
  .filter(msg => msg.type === 'state')
  .map(msg => msg.state);



const timeDrift = inboundMessages
  .map(msg => msg.timestamp - Date.now())
  .slidingWindow(5, 1)
  .map(lags => 
    lags.reduce((sum, lag) => sum + lag, 0) 
    / lags.length);



const state = kefir
  .combine([stateMessages], [timeDrift])
  .map(([state, drift]) => {
    state.countdown = state.countdown - drift;
    return state;
  });
const animationFrames = kefir.repeat(
  () => kefir.fromCallback(
    cb => requestAnimationFrame(cb)
  )
);



class PlayerState extends React.Component {
  componentDidMount() {
    kefir
      .combine([animationFrames], [state])
      .onValue(([_,s]) => this.setState(s));
  }

  render() {
    return <PlayerInterface {...this.state}/>;
  }
}



class PlayerInterface extends React.Component {
  // ...
}
const dispatcher = new EventEmitter();

class PlayerInterface extends React.Component {
  ...

  handleJoin(e) {
    e.preventDefault();
    dispatcher.emit(
      'join', 
      { name: this.state.name });
  }

  click(e) {
    e.preventDefault();
    dispatcher.emit('click', {});
  }
}

const clicks = kefir
  .fromEvents(dispatcher, 'click')
  .map(e => ({ type: 'click' }));

const joins = kefir
  .fromEvents(dispatcher, 'join')
  .map(e => ({ type: 'join', name: e.name }));
const outboundMessages = kefir.merge([
  clicks, 
  joins
]);



kefir
  .combine([outboundMessages], [connections])
  .onValue(([msg, socket]) => {
    socket.send(JSON.stringify(msg));
  });
const roster = dynamicValue([],
  joinings, (players, req) => {
    const message = req.message;
    const name = getUniqueName(message.name, players);
    const teamCounts = players.reduce(
      (counts, p) => { 
        counts[p.team]++; 
        return counts 
      },
      { Red: 0, Blue: 0 });
    const team = 
      teamCounts.Red <= teamCounts.Blue 
      ? 'Red' : 'Blue';
    return players.concat({ 
      client: req.client, 
      id: message.id, 
      name: name, 
      team: team 
    });
  },

  leavings, (players, req) => 
    players.filter(p => p.id !== req.player),

  disconnections, (players, socket) => 
    players.filter(p => p.client !== socket)
);

Common Gotchas

Model the problem, then code

Common Gotchas

circular dependencies

const timerSources = kefir.pool();

const completedTimers = timerSources.flatMap(
  t => waitUntil(t.timestamp).map(() => t.status));


const canStart = roster.map(r => hasOpponents(r));


const waits = kefir
  .merge([
    serverStarts, 
    completedTimers.filter(s => s === 'finishedRound')])
  .filterBy(canStart.map(b => !b))
  .map(() => ({ 
    status: 'finishedWait', 
    timestamp: Date.now() + 30000 }));

const countdowns = completedTimers
  .filter(s => s === 'finishedWait')
  .filterBy(canStart)
  .map(() => ({ 
    status: 'finishedCountdown', 
    timestamp: Date.now() + 5000 }));

const starts = completedTimers
  .filter(s => s === 'finishedCountdown')
  .map(() => ({ 
    status: 'finishedRound', 
    timestamp: Date.now() + 5000 }));


timerSources.plug(waits);
timerSources.plug(countdowns);
timerSources.plug(starts);

Common Gotcha:

Lazy Subscriptions

const gameState = inboundStateServerMessages
  .filter(m => m.type === 'state')
  .map(({ state }) => {
    let teamData = {
      Red: { playerCount: 0, score: state.teamScores.Red },
      Blue: { playerCount: 0, score: state.teamScores.Blue }
    };
    let playersById = {};
    let topPlayer = null;
    state.players.forEach(p => {
      playersById[p.id] = p;
      const team = teamData[p.team];
      team.playerCount++;
      topPlayer = (topPlayer && topPlayer.score > p.score) ? topPlayer : p;
    });
    return {
      players: playersById,
      teams: teamData,
      status: state.status,
      countdown: state.countdown,
      topPlayer: topPlayer
    };
  })
  .onValue(/* Eagerly consume */ () => { })
  .toProperty();

Why FRP?

Convert procedural, low-level event handlers to declarative, high-level operations using well-known operators from functional programming.

With FRP, asynchronous code is more elegant, because it is:

  • Concise: less boilerplate; common tasks can be performed with a minimum of code.
  • Clear: cleanly model your code as data flows transformed using well-understood operations.
  • Consistent: merge the hodgepodge of different async patterns into one single data type.

Thank you!

 

https://github.com/DullReferenceException/braziljs-frp-demo

@apocko

frp

By Jacob Page

frp

Writing elegant JavaScript with Functional Reactive Programming

  • 1,617
Loading comments...