When we talk about writing asynchronous JavaScript we often use timer functions or promises as an example. Whereas the majority of asynchronous code written in modern JavaScript web apps is focused on events caused either by a user interacting with the UI (addEventListener) or some native API (IndexedDB, WebSocket, ServiceWorker). With modern front-end frameworks and the way we pass event handlers it is easy to end up with leaky abstraction.
When you build your application from multiple small components it is a common good practice to move application state to components which are higher in the tree (parent components). Following this pattern, we have developed the concept of so called "container" components. This technique makes it much easier to provide synced state to multiple children components on different levels in the tree.
One of the downsides is that we sometimes have to provide a callback to handle events along with some additional parameters which are supposed to be applied to that callback function. Here is an example:
const User = ({ id, name, remove }) => (
<li>
{name} <button onClick={() => remove(id)}>Remove</button>
</li>
);
class App extends Component {
state = {
users: [{ id: "1", name: "Foo" }, { id: "2", name: "Bar" }],
};
remove = (id) => {
this.setState(({ users }) => ({ users: users.filter(u => u.id !== id) }));
};
render() {
return (
<ul>
{this.state.users.map(({ id, ...user }) =>
<User key={id} id={id} {...user} remove={this.remove} />)}
</ul>
);
}
}
Although User component is not using users id property for presentation, id is required because remove expects to be called for a specific user. As far as I am concerned this is a leaky abstraction. Moreover, if we decide to change id property to uuid we have to revisit User and correct it as well to preserve consistent naming. This might not be the biggest of your concerns when it comes to "id" property but I hope it makes sense and you can see an imperfection here. The cleaner way to do it would be applying id to remove function before it is passed to the User component.
<User key={id} {...user} remove={() => this.remove(id)} />
Unfortunately, this technique has performance implications. On each App render, remove function passed to the User would be a newly created function. Brand new function effectively kills React props check optimizations which are relying on reference equality check and it is a bad practice.
There is a third option (and probably a fourth and a fifth but bear with me). We can combine memoization and currying to create partially applied event handlers without adding too much complexity. A lot of smart words but it is simple:
import { memoize, curry } from "lodash/fp";
const User = ({ name, remove }) => (
<li>
{name} <button onClick={remove}>Remove</button>
</li>
);
class App extends Component {
state = {
users: [{ id: "1", name: "Foo" }, { id: "2", name: "Bar" }],
};
remove = memoize(curry((id, _ev) => {
this.setState(({ users }) => ({ users: users.filter(u => u.id !== id) }));
}));
render() {
return (
<ul>
{this.state.users.map(({ id, ...user }) =>
<User key={id} {...user} remove={this.remove(id)} />)}
</ul>
);
}
}
This is a sweet spot. Each user has its own remove function with id already applied. We don't have to pass, irrelevant for presentation, property to the User and thanks to memoization we did not penalize performance. Each time remove is called with a given id the same function is returned.
this.remove("1") === this.remove("1") // => true
Decorator
Are you into decorators? If you are not up for importing memoize and curry functions, then wrapping your handlers in each container you may would like to go with a property decorator:
// TODO: Reconsider this name, maybe something with "ninja"
function rockstarEventHandler(target, key, descriptor) {
return {
get() {
const memoized = memoize(curry(descriptor.value.bind(this)));
Object.defineProperty(this, key, {
configurable: true,
writable: true,
enumerable: false,
value: memoized,
});
return memoized;
},
};
}
This implementation does not cover all edge cases but it sells the idea. In production you probably want to combine two decorators and leave binding execution context to autobind decorator from jayphelps/core-decorators. Usage:
@rockstarEventHandler
remove(id, _ev) {
this.setState(({ users }) => ({ users: users.filter(u => u.id !== id) }));
}
Conclusion
I cannot say whether that is an optimal solution for you and your team but I am more than happy with this approach writing simpler tests and effectively less glue code.
The downside of this approach is that you will probably have to explain few coworkers the rationale behind all that momoize(curry(() => {}))
thing. Throwing a sentence like "it's just memoized and partially applied function" probably will not be enough. The bright side is you can always point them here!
Photo by Marc-Olivier Jodoin on Unsplash.