Hi! I'm Luke
@luke_dot_js
@lukewestby
humblespark.com
If you're reading .then(), it's too late
Handling effects in JavaScript
@luke_dot_js
@lukewestby
Effect:
Any interaction with the outside world
@luke_dot_js
@lukewestby
const request = (url) => {
return fetch(url).then((r) => r.json());
};
const getUser = (id) => {
return db("users").where({ id }).first()
.then((user) => user.id);
};
const currentOffset = (el) => {
return window.scrollY - el.scrollTop;
};
@luke_dot_js
@lukewestby
@luke_dot_js
@lukewestby
@luke_dot_js
@lukewestby
What is the right way to deal with effects?
@luke_dot_js
@lukewestby
doSomeWork((error, result) => {
if (error) respond(error, 500);
else respond(result, 200);
});
Callbacks
doSomeWork((error, firstResult) => {
if (error) return respond(error, 500);
else {
processFirstResult(firstResult, (secondError, secondResult) => {
if (secondError) return respond(secondError, 500);
else {
if (secondResult.prop) {
doFirstBranchWork(secondResult, (firstBranchError, firstBranchResult) => {
if (firstBranchError) respond(firstBranchError, 500);
else respond(firstBranchResult, 200);
});
} else {
doSecondBranchWork(secondResult, (secondBranchError, secondBranchResult) => {
if (secondBranchError) respond(secondBranchError, 500);
else respond(secondBranchError);
});
}
}
});
});
@luke_dot_js
@lukewestby
doSomeWork()
.then((result) => processFirstResult(result))
.then((result) => {
return result.prop ?
doFirstBranchWork(result) :
doSecondBranchWork(result);
})
.then((result) => respond(result, 200))
.catch((error) => respond(error, 500));
Promises
@luke_dot_js
@lukewestby
@luke_dot_js
@lukewestby
🎉
@luke_dot_js
@lukewestby
😢
const myPromiseFn = (arg) => {
return new Promise((resolve, reject) => {
doWork(arg, (ev) => {
if (ev.error) {
reject(ev.error);
} else {
resolve(ev.data);
}
});
});
});
callbacks
executor
rejection
resolution
@luke_dot_js
@lukewestby
From MDN:
@luke_dot_js
@lukewestby
If we’re doing async work, effects are (almost) always involved
Promises represent the creation of async work
If a Promise is present, we can pretty safely assume the present of a side-effect
@luke_dot_js
@lukewestby
side-effects
@luke_dot_js
@lukewestby
😱😱😱😱😱😱😱😱😱
This is super easy to understand:
This is less easy to understand:
(user) => user.id
db("users").first()
.then((user) => user.id)
@luke_dot_js
@lukewestby
@luke_dot_js
@lukewestby
elm-lang.org
@luke_dot_js
@lukewestby
vs.
const getUser = (id) => {
return db("users")
.where({ id })
.first();
};
const getUser = (id) => ({
action: "db",
table: "users",
operations: [ ["where", { id }], ["first"] ]
});
@luke_dot_js
@lukewestby
Somewhere else...
const dbRunner = ({ table, operations }) => {
const query = db(table);
return operations.reduce(/* ... */, query);
};
@luke_dot_js
@lukewestby
Declare effects in application code
Pause execution of the application code to run the effect
Resume application code when the effect has produced a result
@luke_dot_js
@lukewestby
React
type ReactDOMElement = {
type: string,
props: {
children: ReactNodeList,
className: string,
...
},
key: string | boolean | number | null,
ref: string | null
};
@luke_dot_js
@lukewestby
Cycle.js
@luke_dot_js
@lukewestby
const actionHandlers = {
[Actions.begin](request) {
return Actions.dbCall({
table: "users",
operations: [
["where", { id: request.params.id }],
["first"],
]
});
},
[Actions.dbCallSuccess](request, user) {
return Actions.respond(user.id, 200);
},
[Actions.dbCallFailure](request, error) {
return Actions.respond(error, 500);
}
};
server.route({
method: "GET",
path: "/users/{id}",
handler(request, reply) {
runActions(runners, actionHandlers, request)
.then((result) => {
reply(result.data).statusCode(result.status);
})
.catch((error) => {
reply(error.message).statusCode(error.status);
});
}
});
@luke_dot_js
@lukewestby
const actionHandlers = {
[Actions.begin](request) {
return Actions.dbCall({
table: "users",
operations: [
["where", { id: request.params.id }],
["first"],
]
});
},
[Actions.dbCallSuccess](request, user) {
return Actions.respond(user.id, 200);
},
[Actions.dbCallFailure](request, error) {
return Actions.respond(error, 500);
}
};
const actionHandlers = {
[0](request) {
return dbCall({
table: "users",
operations: [
["where", { id: request.params.id }],
["first"],
]
});
},
[1](request, user) {
return respond(user.id, 200);
},
error(request, error) {
return respond(error, 500);
}
};
server.route({
method: "GET",
path: "/users/{id}",
handler(request, reply) {
runActions(runners, handler(request))
.then((result) => {
reply(result.data)
.statusCode(result.status);
})
.catch((error) => {
reply(error.message)
.statusCode(error.status);
});
}
});
@luke_dot_js
@lukewestby
Generators!
const handler = function* (request) {
try {
const user = yield {
type: "db",
table: "users",
operations: [/* ... */]
};
return respond(user.id, 200);
} catch (error) {
respond(error.message, 500);
}
};
const iterator = handler({ params: { id: 1 } });
let next = iterator.next();
assertDeepEqual(next.value, { type: "db", /* ... */ });
next = iterator.next({ id: 1, name: "Luke", /* ... */ });
assertDeepEqual(next.value, respond(1, 200));
// or
next = iterator.throw(Error("NOPE!");
assertDeepEqual(next.value, respond("NOPE!", 500));
@luke_dot_js
@lukewestby
@luke_dot_js
@lukewestby
🎉
@luke_dot_js
@lukewestby
😢
Two problems:
- Nested calls are uncomfortable
- Parallelization isn’t a given
@luke_dot_js
@lukewestby
const handler = function* (request) {
try {
const user = yield {
type: “db",
table: “users",
operations: [/* ... */]
};
return respond(user.id, 200);
} catch (error) {
respond(error.message, 500);
}
};
@luke_dot_js
@lukewestby
const handler = function* (request) {
try {
const userId = ?? getUserId(request); ??
} catch (error) {
respond(error.message, 500);
}
};
@luke_dot_js
@lukewestby
const handler = function* (request) {
try {
const userId = yield spawn(getUserId, request);
} catch (error) {
respond(error.message, 500);
}
};
@luke_dot_js
@lukewestby
Only one level of continuation is possible with Generators
What if we could yield deeply?
@luke_dot_js
@lukewestby
One-shot delimited continuations with effect handlers
Sebastian Markbåge
@luke_dot_js
@lukewestby
const funcWithEffect = (request) => {
const user = perform { type: “db”, ... };
return user.id;
}
try {
const result = funcWithEffect(request);
} catch effect -> [{ type, ...details }, next] {
runners[type](details)
.then((result) => next(result))
.catch((error) => { throw error; });
}
@luke_dot_js
@lukewestby
This isn't a part of JS
What can we do today?
@luke_dot_js
@lukewestby
monads
@luke_dot_js
@lukewestby
😱😱😱😱😱😱😱😱😱
Task!
Folktale.js - data.task
const Task = require('data.task');
// getUser :: Int -> Task Error User
const getUser = (id) => {
return new Task((resolve, reject) => {
db('table')
.where({ id })
.first()
.then(resolve, reject);
});
};
// userHandler :: Request -> Task Error Response
const userHandler = (request) => {
return getUser(request.params.id)
.map((user) => respond(user, 200))
.rejectedMap((error) => respond(error, 500));
};
@luke_dot_js
@lukewestby
server.route({
method: "GET",
path: "/users/{id}",
handler(request, reply) {
userHandler(request).fork(reply, reply);
}
});
Nothing runs until right here
@luke_dot_js
@lukewestby
Tradeoffs
- Free of side-effects
- Composable
-
Easy to parallelize
- control.async
- Not representable as pure data
- Requires mocking
@luke_dot_js
@lukewestby
Luke's Very Official Recommendation™
On the backend:
Use Tasks
On the frontend:
Try Elm
Try Cycle.js
Use Tasks
Questions?
@luke_dot_js
@lukewestby
If You're Reading .then() It's Too Late
By lukewestby
If You're Reading .then() It's Too Late
- 973