Thinking In React In AngularJS
I love AngularJS. But, I've been digging into ReactJS a lot lately in order to both get more perspective on application development as well as to be able to help my React-using teammates when they encounter problems. So far, I've found the process to be quite fruitful especially when it comes to the cross-pollination of ideas (in both directions). As a fun experiment, I wanted to see what it would feel like to repurpose the "Thinking in React" documentation, by Pete Hunt, but for an AngularJS context. Meaning, what if I took that article and made it about AngularJS, not ReactJS, while trying to apply all of the same principles.
Before we start, I would like to say that this is definitely the direction that my AngularJS code is moving. However, I don't break my AngularJS code down into arbitrarily small components. AngularJS and ReactJS have different strengths. And, in my opinion, I think AngularJS is better about keeping larger chunks of markup easier to reason about, especially with directives like ngRepeat.
=============
CAUTION: So beginneth the experiment. I did have to leave an entire section out because I couldn't figure out how to translate it in a meaningful way.
=============
Thinking in React
(as bastardized by Ben Nadel)
AngularJS is, in my opinion, the premier way to build big, fast Web apps with JavaScript. It has scaled very well for us at InVision App, Inc.
One of the many great parts of AngularJS is how it makes you think about apps as you build them. In this post, I'll walk you through the thought process of building a searchable product data table using AngularJS.
Start with a mock
Imagine that we already have a JSON API and a mock from our designer. Our designer apparently isn't very good because the mock looks like this:
Our JSON API returns some data that looks like this:
[
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];
Step 1: break the UI into a component hierarchy
The first thing you'll want to do is to draw boxes around every component (and subcomponent) in the mock and give them all names. If you're working with a designer, they may have already done this, so go talk to them! Their Photoshop layer names may end up being the names of your React components!
But how do you know what should be its own component? Just use the same techniques for deciding if you should create a new function or object. One such technique is the single responsibility principle, that is, a component should ideally only do one thing. If it ends up growing, it should be decomposed into smaller subcomponents.
You'll see here that we have three components in our simple app.
- FilterableProductTable (orange): contains the entirety of the example.
- SearchBar (blue): receives all user input.
- ProductTable (green): displays and filters the data collection based on user input.
Ben's Note: With AngularJS, you have to take on more of a "shadow-DOM" mindset since replacing an entire element isn't an obvious task. In most cases, this is fine; however, with Tables, we have to make some concessions since we need to generate valid HTML table markup. That said, unlike Pete's example, I would not try to break up the actual table into smaller components - to me, the table itself is a "single responsibility."
Now that we've identified the components in our mock, let's arrange them into a hierarchy. This is easy. Components that appear within another component in the mock should appear as a child in the hierarchy:
- FilterableProductTable
- -- SearchBar
- -- ProductTable
Step 2: Build a static version in AngularJS
Run this step in my JavaScript Demos project on GitHub.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Thinking In React In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="//fonts.googleapis.com/css?family=Open+Sans:300,400,600,700"></link>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Thinking In React In AngularJS
</h1>
<h2>
Step 2: Build A Static Version In AngularJS
</h2>
<filterable-product-table
products="vm.products"
style="width: 400px ;">
</filterable-product-table>
<p ng-if="false">
CAUTION: This demo using the back-tick to define "template strings"
and will not work in older browsers like <strong>Safari</strong>
and <strong>Internet Explorer</strong>. If you're seeing this message,
your browser needs to be updated.
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.5.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function( $scope ) {
var vm = this;
// Expose the products on the demo so that they can be passed into
// our filterable demo component.
vm.products = [
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a filterable table of products and prices.
angular.module( "Demo" ).directive(
"filterableProductTable",
function filterableProductTable() {
// Return the directive configuration object.
return({
controller: function FilterableProductTableController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
},
controllerAs: "vm",
restrict: "E",
scope: {
products: "="
},
template:
`
<div>
<search-bar></search-bar>
<product-table products="props.products"></product-table>
</div>
`
});
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the search form for the filterable table component.
angular.module( "Demo" ).directive(
"searchBar",
function searchBar() {
// Return the directive configuration object.
return({
controller: function SearchBarController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
},
controllerAs: "vm",
restrict: "E",
scope: {},
template:
`
<form>
<input type="text" placeholder="Search..." />
<p>
<label>
<input type="checkbox" /> Only show products in stock
</label>
</p>
</form>
`
});
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the table grid for the filterable table component.
angular.module( "Demo" ).directive(
"productTable",
function productTable() {
// Return the directive configuration object.
return({
controller: function ProductTableController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// Let's transform the incoming products array into something that
// is a bit easier to render. Rather than a single array, we're
// going to break the products down in a collection of Categories,
// each of which has a collection of products.
vm.categories = [];
// I help keep track of the group generation.
var category = null;
var lastCategory = null;
// Divide products into categories.
props.products.forEach(
function iterator( product ) {
if ( product.category !== lastCategory ) {
category = {
name: ( lastCategory = product.category ),
products: []
};
vm.categories.push( category );
}
category.products.push({
name: product.name,
stocked: product.stocked,
price: product.price
});
}
);
},
controllerAs: "vm",
restrict: "E",
scope: {
products: "="
},
template:
`
<table>
<col width="80%" />
<col width="20%" />
<thead>
<tr>
<th>
Name
</th>
<th>
Price
</th>
</tr>
</thead>
<tbody ng-repeat="category in vm.categories track by category.name">
<tr>
<th colspan="2">
{{ category.name }}
</th>
</tr>
<tr
ng-repeat="product in category.products track by product.name"
ng-class="{ 'out-of-stock': ! product.stocked }">
<td>
{{ product.name }}
</td>
<td>
{{ product.price }}
</td>
</tr>
</tbody>
</table>
`
});
}
);
</script>
</body>
</html>
Now that you have your component hierarchy, it's time to implement your app. The easiest way is to build a version that takes your data model and renders the UI but has no interactivity. It's best to decouple these processes because building a static version requires a lot of typing and no thinking, and adding interactivity requires a lot of thinking and not a lot of typing. We'll see why.
To build a static version of your app that renders your data model, you'll want to build components that reuse other components and pass data using attributes. Attributes are a way of passing data from parent to child. We can use state for this step; but, let's try to keep state management to a minimum.
Ben's Note: While the ReactJS version of this can avoid state entirely because it does all of the data transformation within the render() method, I do use state to create an intermediary data structure that is easier to render. I could have moved all of that processing into a method call that gets run on each digest; but, this feels expensive and I tend to expose properties over methods.
At the end of this step, you'll have a library of reusable components that render your data model. The component at the top of the hierarchy (FilterableProductTable) will take your data model as an attribute. At this point, we can run the initial render of the components; but, the components will not be updated with the underlying data model.
Ben's Note: If we did not have intermediary data structures, the views would automatically update at this point. But, this particular application does not lend itself well to that.
Step 3: Identify the minimal (but complete) representation of UI state
Ben's Note: I couldn't really translate this section into an AngularJS context because the approaches, while similar, are too different for this particular application. I ended up creating more state than you may normally in order to 1) Make the rendering easier and 2) prevent the ngModel binding from directly altering the props being passed-in via the isolate-scope binding.
Step 4: Identify where your state should live
Run this step in my JavaScript Demos project on GitHub.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Thinking In React In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Thinking In React In AngularJS
</h1>
<h2>
Step 4: Identify Where Your State Should Live
</h2>
<filterable-product-table
products="vm.products"
style="width: 400px ;">
</filterable-product-table>
<p ng-if="false">
CAUTION: This demo using the back-tick to define "template strings"
and will not work in older browsers like <strong>Safari</strong>
and <strong>Internet Explorer</strong>. If you're seeing this message,
your browser needs to be updated.
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.5.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function( $scope ) {
var vm = this;
// Expose the products on the demo so that they can be passed into
// our filterable demo component.
vm.products = [
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a filterable table of products and prices.
angular.module( "Demo" ).directive(
"filterableProductTable",
function filterableProductTable() {
// Return the directive configuration object.
return({
controller: function FilterableProductTableController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// The filterable table component will hold the state of the
// filtering so that it be passed both into the search component
// as well as into the table. This the highest common point.
// --
// CAUTION: We have some values hard-coded here since we are
// testing the one-way data flow and want to make sure that the
// incoming data is being applied.
vm.filterText = "ball";
vm.inStockOnly = true;
},
controllerAs: "vm",
restrict: "E",
scope: {
products: "="
},
template:
`
<div>
<search-bar
filter-text="vm.filterText"
in-stock-only="vm.inStockOnly">
</search-bar>
<product-table
products="props.products"
filter-text="vm.filterText"
in-stock-only="vm.inStockOnly">
</product-table>
</div>
`
});
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the search form for the filterable table component.
angular.module( "Demo" ).directive(
"searchBar",
function searchBar() {
// Return the directive configuration object.
return({
controller: function SearchBarController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// I hold the form values for the ng-model bindings. Since we
// don't want the ng-model bindings to alter the references in
// the calling context, we'll create an intermediary structure
// to hold the local data.
vm.form = {
filterText: "",
inStockOnly: false
};
// Since we're using an intermediary data structure, we have to
// watch for changes on the props; and, when they change, we
// have to synchronize the local form inputs.
// --
// NOTE: Watch configuration will set initial values.
$scope.$watchCollection(
"[ props.filterText, props.inStockOnly ]",
function handlePropsChange( newValues, oldValues ) {
vm.form.filterText = props.filterText;
vm.form.inStockOnly = props.inStockOnly;
}
);
},
controllerAs: "vm",
restrict: "E",
scope: {
filterText: "=",
inStockOnly: "="
},
template:
`
<form>
<input type="text" ng-model="vm.form.filterText" placeholder="Search..." />
<p>
<label>
<input type="checkbox" ng-model="vm.form.inStockOnly" />
Only show products in stock
</label>
</p>
</form>
`
});
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the table grid for the filterable table component.
angular.module( "Demo" ).directive(
"productTable",
function productTable() {
// Return the directive configuration object.
return({
controller: function ProductTableController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// Let's transform the incoming products array into something that
// is a bit easier to render. Rather than a single array, we're
// going to break the products down in a collection of Categories,
// each of which has a collection of products.
vm.categories = [];
// Since we're employing a data-transformation, we have to watch
// for changes on the props; and, when the products or the filter
// criteria changes, we have to synchronize the transformed structure.
// --
// NOTE: Watch configuration will set initial values.
$scope.$watchCollection(
"[ props.products, props.filterText, props.inStockOnly ]",
function handlePropsChange() {
vm.categories = getFilteredProducts(
props.products,
props.filterText,
props.inStockOnly
);
}
);
// ---
// PRIVATE METHODS.
// ---
// I return the category breakdown for the given products after
// the given filtering has been applied.
function getFilteredProducts( products, filterText, inStockOnly ) {
var categories = [];
var category = null;
var lastCategory = null;
filterText = filterText.toLowerCase();
products
// Filter out the products that don't match the current criteria.
.filter(
function operator( product ) {
// Filter based on text.
if (
filterText &&
( product.name.toLowerCase().indexOf( filterText ) === -1 )
) {
return( false );
}
// Filter based on stock status.
if ( inStockOnly && ! product.stocked ) {
return( false );
}
// If we made it this far, the product was not
// filtered-out of the results.
return( true );
}
)
// Now that we have the filtered products, break them
// down into the different categories. And, since we pre-
// filtered the products, we know that we won't get any
// empty categories in the final breakdown.
.forEach(
function iterator( product ) {
if ( product.category !== lastCategory ) {
category = {
name: ( lastCategory = product.category ),
products: []
};
categories.push( category );
}
category.products.push({
name: product.name,
stocked: product.stocked,
price: product.price
});
}
)
;
return( categories );
}
},
controllerAs: "vm",
restrict: "E",
scope: {
products: "=",
filterText: "=",
inStockOnly: "="
},
template:
`
<table>
<col width="80%" />
<col width="20%" />
<thead>
<tr>
<th>
Name
</th>
<th>
Price
</th>
</tr>
</thead>
<!-- If we have data to display. -->
<tbody
ng-if="vm.categories.length"
ng-repeat="category in vm.categories track by category.name">
<tr>
<th colspan="2">
{{ category.name }}
</th>
</tr>
<tr
ng-repeat="product in category.products track by product.name"
ng-class="{ 'out-of-stock': ! product.stocked }">
<td>
{{ product.name }}
</td>
<td>
{{ product.price }}
</td>
</tr>
</tbody>
<!-- If we have no data to display. -->
<tbody ng-if="( ! vm.categories.length )">
<tr>
<td colspan="2" class="no-data">
<em>No products match your criteria.</em>
</td>
</td>
</tbody>
</table>
`
});
}
);
</script>
</body>
</html>
OK, so we've identified what the minimal set of app state is. Next, we need to identify which component mutates, or owns, this state. It may not be immediately clear which component should own what state. This is often the most challenging part for newcomers to understand, so follow these steps to figure it out:
For each piece of state in your application:
- Identify every component that renders something based on that state.
- Find a common owner component (a single component above all the components that need the state in the hierarchy).
- Either the common owner or another component higher up in the hierarchy should own the state.
- If you can't find a component where it makes sense to own the state, create a new component simply for holding the state and add it somewhere in the hierarchy above the common owner component.
Let's run through this strategy for our application:
- ProductTable needs to filter the product list based on state and SearchBar needs to display the search text and checked state.
- The common owner component is FilterableProductTable.
- It conceptually makes sense for the filter text and checked value to live in FilterableProductTable
Cool, so we've decided that our state lives in FilterableProductTable. First, let's define the filterText and inStockOnly on the view-model to reflect the initial state of your application. Then, pass filterText and inStockOnly to ProductTable and SearchBar as an attribute. Finally, use these attributes to filter the rows in ProductTable and set the values of the form fields in SearchBar.
You can start seeing how your application will behave: set filterText to "ball" and refresh your app. You'll see that the data table is updated correctly.
Step 5: Add inverse data flow
Run this step in my JavaScript Demos project on GitHub.
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Thinking In React In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">
<h1>
Thinking In React In AngularJS
</h1>
<h2>
Step 5: Add Inverse Data Flow
</h2>
<filterable-product-table
products="vm.products"
style="width: 400px ;">
</filterable-product-table>
<p ng-if="false">
CAUTION: This demo using the back-tick to define "template strings"
and will not work in older browsers like <strong>Safari</strong>
and <strong>Internet Explorer</strong>. If you're seeing this message,
your browser needs to be updated.
</p>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.5.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the application.
angular.module( "Demo" ).controller(
"AppController",
function( $scope ) {
var vm = this;
// Expose the products on the demo so that they can be passed into
// our filterable demo component.
vm.products = [
{category: "Sporting Goods", price: "$49.99", stocked: true, name: "Football"},
{category: "Sporting Goods", price: "$9.99", stocked: true, name: "Baseball"},
{category: "Sporting Goods", price: "$29.99", stocked: false, name: "Basketball"},
{category: "Electronics", price: "$99.99", stocked: true, name: "iPod Touch"},
{category: "Electronics", price: "$399.99", stocked: false, name: "iPhone 5"},
{category: "Electronics", price: "$199.99", stocked: true, name: "Nexus 7"}
];
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide a filterable table of products and prices.
angular.module( "Demo" ).directive(
"filterableProductTable",
function filterableProductTable() {
// Return the directive configuration object.
return({
controller: function FilterableProductTableController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// The filterable table component will hold the state of the
// filtering so that it be passed both into the search component
// as well as into the table. This the highest common point.
vm.filterText = "";
vm.inStockOnly = false;
// Expose the public methods.
vm.handleUserInput = handleUserInput;
// ---
// PUBLIC METHODS.
// ---
// I handle user input from the search box.
function handleUserInput( filterText, inStockOnly ) {
vm.filterText = filterText;
vm.inStockOnly = inStockOnly;
}
},
controllerAs: "vm",
restrict: "E",
scope: {
products: "="
},
template:
`
<div>
<search-bar
filter-text="vm.filterText"
in-stock-only="vm.inStockOnly"
on-user-input="vm.handleUserInput( filterText, inStockOnly )">
</search-bar>
<product-table
products="props.products"
filter-text="vm.filterText"
in-stock-only="vm.inStockOnly">
</product-table>
</div>
`
});
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the search form for the filterable table component.
angular.module( "Demo" ).directive(
"searchBar",
function searchBar() {
// Return the directive configuration object.
return({
controller: function SearchBarController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// I hold the form values for the ng-model bindings. Since we
// don't want the ng-model bindings to alter the references in
// the calling context, we'll create an intermediary structure
// to hold the local data.
vm.form = {
filterText: props.filterText,
inStockOnly: props.inStockOnly
};
// Since we're using an intermediary data structure, we have to
// watch for changes on the props; and, when they change, we
// have to synchronize the local form inputs.
// --
// NOTE: Watch configuration will set initial values.
$scope.$watchCollection(
"[ props.filterText, props.inStockOnly ]",
function handlePropsChange() {
vm.form.filterText = props.filterText;
vm.form.inStockOnly = props.inStockOnly;
}
);
// In order to facilitate the bi-directional data flow, we have
// to watch for changes on the form inputs; and, when they happen,
// we have to invoke the user input callback.
$scope.$watchCollection(
"[ vm.form.filterText, vm.form.inStockOnly ]",
function handleInputChange( newValues, oldValues ) {
// Ignore the configuration step since this doesn't
// actually represent user interaction.
if ( newValues === oldValues ) {
return;
}
props.onUserInput({
filterText: vm.form.filterText,
inStockOnly: vm.form.inStockOnly
});
}
);
},
controllerAs: "vm",
restrict: "E",
scope: {
filterText: "=",
inStockOnly: "=",
onUserInput: "&"
},
template:
`
<form>
<input type="text" ng-model="vm.form.filterText" placeholder="Search..." />
<p>
<label>
<input type="checkbox" ng-model="vm.form.inStockOnly" />
Only show products in stock
</label>
</p>
</form>
`
});
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I provide the table grid for the filterable table component.
angular.module( "Demo" ).directive(
"productTable",
function productTable() {
// Return the directive configuration object.
return({
controller: function ProductTableController( $scope ) {
var vm = this;
var props = $scope.props = $scope;
// Let's transform the incoming products array into something that
// is a bit easier to render. Rather than a single array, we're
// going to break the products down in a collection of Categories,
// each of which has a collection of products.
vm.categories = [];
// Since we're employing a data-transformation, we have to watch
// for changes on the props; and, when the products or the filter
// criteria changes, we have to synchronize the transformed structure.
// --
// NOTE: Watch configuration will set initial values.
$scope.$watchCollection(
"[ props.products, props.filterText, props.inStockOnly ]",
function handlePropsChange() {
vm.categories = getFilteredProducts(
props.products,
props.filterText,
props.inStockOnly
);
}
);
// ---
// PRIVATE METHODS.
// ---
// I return the category breakdown for the given products after
// the given filtering has been applied.
function getFilteredProducts( products, filterText, inStockOnly ) {
var categories = [];
var category = null;
var lastCategory = null;
filterText = filterText.toLowerCase();
products
// Filter out the products that don't match the current criteria.
.filter(
function operator( product ) {
// Filter based on text.
if (
filterText &&
( product.name.toLowerCase().indexOf( filterText ) === -1 )
) {
return( false );
}
// Filter based on stock status.
if ( inStockOnly && ! product.stocked ) {
return( false );
}
// If we made it this far, the product was not
// filtered-out of the results.
return( true );
}
)
// Now that we have the filtered products, break them
// down into the different categories. And, since we pre-
// filtered the products, we know that we won't get any
// empty categories in the final breakdown.
.forEach(
function iterator( product ) {
if ( product.category !== lastCategory ) {
category = {
name: ( lastCategory = product.category ),
products: []
};
categories.push( category );
}
category.products.push({
name: product.name,
stocked: product.stocked,
price: product.price
});
}
)
;
return( categories );
}
},
controllerAs: "vm",
restrict: "E",
scope: {
products: "=",
filterText: "=",
inStockOnly: "="
},
template:
`
<table>
<col width="80%" />
<col width="20%" />
<thead>
<tr>
<th>
Name
</th>
<th>
Price
</th>
</tr>
</thead>
<!-- If we have data to display. -->
<tbody
ng-if="vm.categories.length"
ng-repeat="category in vm.categories track by category.name">
<tr>
<th colspan="2">
{{ category.name }}
</th>
</tr>
<tr
ng-repeat="product in category.products track by product.name"
ng-class="{ 'out-of-stock': ! product.stocked }">
<td>
{{ product.name }}
</td>
<td>
{{ product.price }}
</td>
</tr>
</tbody>
<!-- If we have no data to display. -->
<tbody ng-if="( ! vm.categories.length )">
<tr>
<td colspan="2" class="no-data">
<em>No products match your criteria.</em>
</td>
</td>
</tbody>
</table>
`
});
}
);
</script>
</body>
</html>
So far, we've built an app that renders correctly as a function of attributes and state flowing down the hierarchy. Now it's time to support data flowing the other way: the form inputs deep in the hierarchy need to update the state in FilterableProductTable.
While AngularJS makes two-way data binding possible, we're going to make it explicit in this demo so that one component doesn't end up mutating data that it doesn't "own."
Let's think about what we want to happen. We want to make sure that whenever the user changes the form, we update the state to reflect the user input. Since components should only update their own state, FilterableProductTable will pass a callback to SearchBar that will fire whenever the state should be updated. We can use the $watchCollection() binding on the inputs to be notified of it.
Though this sounds complex, it's really just a few lines of code. And it's really explicit how your data is flowing throughout the app.
And that's it
Hopefully, this gives you an idea of how to think about building components and applications with AngularJS. While it may be a little more typing than you're used to, remember that code is read far more than it's written, and it's extremely easy to read this modular, explicit code. As you start to build large libraries of components, you'll appreciate this explicitness and modularity, and with code reuse, your lines of code will start to shrink. :)
======
Hmmm, how did that feel for you?
Want to use code from this post? Check out the license.
Reader Comments
Do you plan to update this article for Angular 4+?