The file tree in Brackets is a bit complex and I did an experiment to see if I could use React to make it less complex. React’s claim to fame is that its programming model is extraordinarily simple: you tell React how the page should look and behave and React makes it so.
Note: I’m assuming some basic familiarity with React here, but I’m going to spend most of my words here on how I applied React in this experiment.
I’ll make it clear up front that React is not the only way to simplify this code. However, much of the complexity around the file tree arises because of the need to synchronize state between the model (file objects and the currently selected file) and the view. That’s exactly the kind of code that just melts away with React.
For example, in ProjectManager.js, whenever a file is added to the “Working Set” in Brackets, we want to be sure that the file is not selected in the file tree:
- find out which file is being viewed
- see if that file is focused in the file tree
- if it is, hunt down the
<li>
element for that file in the tree - once you’ve got it, confirm that it’s actually selected in the tree
- if the parent folder is closed, don’t unselect it because that would open up the parent folder
- regardless of the parent folder’s state, we’re going to tell the tree to “deselect all”
- if the parent folder is open, we’ll also explicitly set the node to not be selected, with some ominous sounding warning about not wanting to trigger another selection change event
- if those other conditions didn’t fire, we still have two other places in which we’ll deselect all.
- finally, we get to call
_redraw
In code, that’s:
function _documentSelectionFocusChange() {
var curFile = EditorManager.getCurrentlyViewedPath();
if (curFile && _hasFileSelectionFocus()) {
var nodeFound = $("#project-files-container li").is(function (index) {
var $treeNode = $(this),
entry = $treeNode.data("entry");
if (entry && entry.fullPath === curFile) {
if (!_projectTree.jstree("is_selected", $treeNode)) {
if ($treeNode.parents(".jstree-closed").length) {
//don't auto-expand tree to show file - but remember it if parent is manually expanded later
_projectTree.jstree("deselect_all");
_lastSelected = $treeNode;
} else {
//we don't want to trigger another selection change event, so manually deselect
//and select without sending out notifications
_projectTree.jstree("deselect_all");
_projectTree.jstree("select_node", $treeNode, false); // sets _lastSelected
}
}
return true;
}
return false;
});
// file is outside project subtree, or in a folder that's never been expanded yet
if (!nodeFound) {
_projectTree.jstree("deselect_all");
_lastSelected = null;
}
} else if (_projectTree !== null) {
_projectTree.jstree("deselect_all");
_lastSelected = null;
}
_redraw(true);
}
The React version of this function looks like this:
function _documentSelectionFocusChange() {
_renderTree();
}
All of that manual code for synchronizing the DOM with the state of our data just disappears.
For many of the events that were possible on the tree, previously there would be a tangled bit of code to interpret that event. The new equivalents just change the data and call _renderTree()
again. For example, in the new code, if you click on the disclosure triangle to open a folder, togglePath
gets called:
function _togglePath(path, open) {
if (open) {
openPaths[path] = true;
} else {
delete openPaths[path];
}
_renderTree();
}
In many React programs, whether a tree node is open or closed is often just tracked as state within the React component. In Brackets, we persist the open/closed state of the folders in the tree, so that data is part of our model code and not just current component state.
_renderTree()
looks like this:
_renderTree = function () {
if (!_projectRoot) {
return;
}
var curDoc = DocumentManager.getCurrentDocument(),
selected = curDoc && curDoc.file ? curDoc.file.fullPath : "";
FileTreeView.render($projectTreeContainer[0], _projectRoot, {
openPaths: openPaths,
selected: selected,
setSelected: _setSelected,
context: fileContext,
setContext: _setContext,
togglePath: _togglePath,
rename: rename,
dirsFirst: PreferencesManager.get(SORT_DIRECTORIES_FIRST)
});
return new $.Deferred().resolve();
};
The old _renderTree
is about 200 lines of code. To be fair, that code is responsible for setting up the event handlers on the tree and the new FileTreeView.jsx file in the React implementation does that job. But, FileTreeView.jsx also replaces the entire jstree library we were using as well.
As I get into my React code, I should note that I’m new to React. That said, since I did this experiment, I’ve read some articles that backed up my approach as being reasonable:
- Operate in a “functional” manner – pass in data, get output, no side effects
- Pass in callback functions as the way to communicate state changes the user has requested
- avoid
setState
, preferring to use props
It turns out that I failed on the third point, but I’ll get back to that. Also, I have since heard about the “Flux” architecture (todo MVC example) but I haven’t yet decided if it’s a good fit here.
In FileTreeView.jsx, the render function is not very interesting because it just converts the incoming parameters into a call to instantiate a FileTreeView component. FileTreeView starts by creating a <ul>
full of DirectoryNode
s:
var FileTreeView = React.createClass({
render: function () {
return (
<ul className="jstree-no-dots jstree-no-icons">
<DirectoryNode
key={this.props.root.fullPath}
directory={this.props.root}
open={true}
skipRoot={true}
selected={this.props.selected}
setSelected={this.props.setSelected}
context={this.props.context}
setContext={this.props.setContext}
rename={this.props.rename}
togglePath={this.props.togglePath}
openPaths={this.props.openPaths}
dirsFirst={this.props.dirsFirst}/>
</ul>
);
}
});
I kept the same classes that the old tree had so that I didn’t have to monkey with the styling at all. DirectoryNode
s need to receive all of the props because rendering for a subtree is the same process as rendering the whole tree. From recent Hacker News comments: this style “leads to a lot of cruft and irrelevant code in the intermediate components”. I can see how that’s possible, though in this case there’s not that many props that we need to pass down. Later in that thread, Dustin Getz sums it up as “there are more wires, but the wires are straight and bundled, not a ratsnest of spaghetti references” which is how I see it. This code is straightforward and understandable, but if there was a lot more to pass in I might want to use another mechanism such as cursors.
DirectoryNode
is the one part that has to deal with the props not containing the complete data, requiring me to use React’s state
. The Directory
objects in Brackets’ file system abstraction don’t provide any way to get the directory contents synchronously, even though they are often cached. When the DirectoryNode
component is being created, it has to request its contents and then uses setState
to update itself. Here’s that bit of code from the top of DirectoryNode
:
var DirectoryNode = React.createClass({
getInitialState: function () {
return {};
},
loadContents: function () {
this.props.directory.getContents(function (err, contents) {
if (!err) {
this.setState({
contents: contents
});
}
}.bind(this));
},
componentDidMount: function () {
var open = this.props.open;
if (open) {
this.loadContents();
}
},
That code isn’t bad. It gets the directory object’s contents and will magically update itself as soon as the contents are ready. It just would be cleaner if the loading of data was handled outside of FileTreeView entirely with updated data passed in via props.
When the user clicks on a directory to open it, the togglePath
callback is called:
handleClick: function () {
if (this.props.togglePath) {
var newOpen = !this.props.open;
if (newOpen && !this.state.contents) {
this.loadContents();
}
this.props.togglePath(this.props.directory.fullPath, !this.props.open);
}
return false;
},
Notice how there’s no difficulty in mapping the DOM nodes back to the directory objects. The directory object is in hand right here in the click handler, and so the call to togglePath
is completely straightforward.
Next, we get to the DirectoryNode.render
method, which is called for each directory every time there is a change. We have two different ways to sort the entries, so we get that out of the way first:
if (this.state.contents) {
var dirsFirst = this.props.dirsFirst;
nodes = _(this.state.contents).clone().sort(function (a, b) {
if (dirsFirst) {
if (a.isDirectory && !b.isDirectory) {
return -1;
} else if (!a.isDirectory && b.isDirectory) {
return 1;
}
}
return FileUtils.compareFilenames(a.name, b.name, false);
}).map(function (entry) {
return this._formatEntry(entry);
}.bind(this));
} else {
nodes = []
}
What? Re-sorting the contents every time through render? JavaScript is pretty fast. I wouldn’t jump to conclusions about performance bottlenecks without profiling. Besides, if this did turn out to be the bottleneck, it would be quick to fix.
The other part that goes along with the sorting is the generation of the child components via _formatEntry
. This is how the tree is created. Taking a diversion into _formatEntry
:
_formatEntry: function (entry) {
if (entry.isDirectory) {
var open = !!this.props.openPaths[entry.fullPath];
return (
<DirectoryNode
key={entry.fullPath}
open={open}
openPaths={this.props.openPaths}
selected={this.props.selected}
setSelected={this.props.setSelected}
context={this.props.context}
setContext={this.props.setContext}
rename={this.props.rename}
togglePath={this.props.togglePath}
directory={entry}/>
);
} else {
if (this.props.rename && this.props.context === entry.fullPath) {
return (
<FileRename
key={entry.fullPath}
cancel={this.props.rename.cancel}
file={entry}/>
);
}
return (
<FileNode
key={entry.fullPath}
selected={this.props.selected === entry.fullPath}
setSelected={this.props.setSelected}
context={this.props.context === entry.fullPath}
setContext={this.props.setContext}
file={entry}/>
);
}
}
One of the things I really like about React code is that it’s all very straightforward. You can nest components just as you would nest elements in HTML and you can also do things more imperatively where it makes sense, as above.
_formatEntry
shows that we have three different custom components in our tree: DirectoryNode
, FileNode
and FileRename
. We allow renaming files in place in our tree, and FileRename
is a component with a textbox rather than just a label for the file.
Jumping back to the bottom of render
:
var open = this.props.open ? "open" : "closed";
return (
<li className={"jstree-" + open} onClick={this.handleClick}>
<ins className="jstree-icon"> </ins>
<a href="#">
<ins className="jstree-icon"> </ins>
{this.props.directory.name}
</a>
<ul>
{nodes}
</ul>
</li>
);
This is the part where I just copied the exact DOM structure that jstree was giving us, again so that I didn’t have to think about styling at all. Those nodes
that were generated by calls to _formatEntry
are easily inserted in place in that sub-<ul>
.
I won’t walk through the rest of FileTreeView.jsx, but you can follow the link if you want to see what it’s like in its entirety.
My Impressions of Working With React
Before I get to my final summary, here’s one additional thing unrelated to my code: React had some great error messages. As I was learning, React would send messages to the console that told me “don’t do X, do Y instead”. Those were incredibly helpful, and I appreciated that attention to developer ergonomics.
As far as this file tree experiment goes, I was quite pleased with the experience of working with React. The resulting code was simpler and easier to follow. Had I made this new file tree production-ready, I’m guessing I would have ended up with slightly less code than in the current implementation, in addition to eliminating the need for a 4,500 line library. I’m also guessing that the new code structure would be easier to write tests for, though I did not get into unit testing with React while I was working on this.
Check out the follow-up to this article in which I talk about the implications of using React in Brackets.