Talks

Rethinking the View Layer with Components

Rethinking the View Layer with Components

by Joel Hawksley

In the presentation "Rethinking the View Layer with Components" at RailsConf 2019, Joel Hawksley from GitHub explores a new approach to redefining the Rails view layer by leveraging ideas from React components. He emphasizes the need for adaptation as Rails' view layer has remained largely unchanged over many years despite significant advancements in web development practices.

Key Points Discussed:

  • Creativity and Innovation: The concept of creativity is discussed in relation to generating new ideas by reapplying existing ones, setting the tone for the presentation.
  • Problems with Current Rails Views: Hawksley highlights the shortcomings of the traditional Rails view layer, including difficulties in testing, auditing with code coverage, reasoning about data flow, and maintaining basic Ruby standards. Rails views often follow implicit method signatures, making them inconsistent with standard Ruby practices.
  • Progressive Enhancement: He explains GitHub's commitment to progressive enhancement, which allows users in low-powered devices or outdated browsers to interact with the application without relying heavily on JavaScript.
  • Comparing Rails and React: React’s use of components is presented as providing multiple advantages, such as encapsulation of UI elements, straightforward data flow, and simplified isolated testing which is not typically available in Rails views.
  • Action View Component: The introduction of an experimental Rails feature, Action View Component, allows views to be tested in isolation, efficiently and in line with Ruby’s standards, thereby facilitating better code quality and maintainability.
  • Examples of Refactoring: The presenter walks through an example of transforming a hard-to-test Rails view into a component, detailing the steps from setting up unit tests to creating a new badge component. This includes implementing proper validations and simplifying data flow.
  • Performance Benefits: By adopting this new architecture, they achieved significant performance improvements with tests running approximately 240 times faster, transitioning from six seconds for traditional tests to about 25 milliseconds for component tests.
  • Implementation and Future Vision: Hawksley concludes by discussing how GitHub has integrated these components into production with ongoing plans for wider implementation across their view layers, focusing on standardizing UI component rendering.

The session emphasizes the importance of evolving Rails' view architecture to improve testing efforts, maintainability, and performance, ultimately fostering a more robust application structure that aligns with modern web practices.

00:00:20.840 All right, good morning everyone. My name is Joel and I work at GitHub.
00:00:32.489 Creativity is the ability to imagine something new. It is not the ability to create something out of nothing, but to generate new ideas by combining, changing, or reapplying existing ideas. Today we're going to do just that. We're going to take ideas from React and reapply them in Rails. In doing so, we're going to take a template that is hard to test thoroughly, impossible to audit with code coverage tools, makes it difficult to reason about data flow, and fails basic Ruby code standards, and refactor it into a new experimental addition to Rails called Action View Component.
00:01:07.140 This new addition will be tested thoroughly in isolation, audited with code coverage tools, only receive the data it needs, and follow the code standards of Ruby. It also happens to be over 200 times faster to test. But first, what even is a view? Views are functions that input data and return HTML. So how has the Rails view layer evolved over the years? The reality is that it's been pretty stable. Rails still ships with ERB like it did in 2005.
00:01:26.459 In 2012, Rails 4 added Turbo Links, and in 2016, Rails 5 added API Mode. However, in the last couple of years, the winds have begun to change. I think it's telling what DHH said when Rails 5 was released: 'Rails is not only a great choice when you want to build a full stack application that uses server-side rendering of HTML templates, but also a great companion for the new crop of client-side JavaScript or native applications that just need the backend to speak JSON.' The reality is that the history of the Rails view layer is one of most of us moving away from it.
00:02:21.420 So what does the view layer look like at GitHub? We're still using ERB. So why isn't GitHub a single-page app like everything else these days? The main reason is progressive enhancement. While JavaScript makes our user experience more pleasant, most of our app works without it. So why do we do this? There are a couple of reasons. The first one is performance. While most of us here are lucky to be using modern, powerful devices, many of our new users are in developing countries.
00:03:05.579 They are using low-powered netbooks, Chromebooks, or tablets which can buckle under heavy JavaScript. Another reason is browser support. Since we don't need JavaScript to run our site, we can simply turn it off for older browsers that are harder to develop for, which makes our JavaScript easier and cheaper to maintain.
00:03:35.159 So how do we do it? We manage a couple of tiers of JavaScript bundles. Our first bundle for fully supported browsers contains normal JavaScript. We have a second tier of polyfills for those that need it, and then for unsupported browsers, we only serve a smaller set of polyfills. So when we deprecated browsers, we move them through these three tiers of support. What does progressive enhancement look like in practice? Take, for example, posting a new comment on an issue. You click the comment button, and we have some JavaScript that intercepts the click and makes an AJAX request that returns DOM nodes for the sidebar comment form and timeline.
00:04:07.549 We then inject these results into the page using PJAX, which, if you aren't familiar with it, is basically Turbolinks. What's great about this is that if JavaScript is turned off, we just make a normal request and reload the page.
00:04:46.080 So what is it like to work on GitHub? I recently worked on adding sticky headers to pull requests and issue pages. The feature shipped in January, and as part of that project, I got to know this little piece of our user interface that we call the 'issue badge' really well. We use the issue badge to display the status of issues and pull requests. We use it about a dozen or two places throughout the application. It's part of our design system, which we call Primer, our own version of Bootstrap.
00:05:15.660 Before we dig into that too much, let's talk about our data model. When we're talking about issues and pull requests, a pull request is simply an issue with an associated pull request object. All pull requests are issues, but not all issues are pull requests. We render the issue badge with a partial.
00:05:41.490 Depending on the state of the pull request or issue, we render an icon, label, and color that display the state of the issue or pull requests. Wanting to know more about the behavior of this view, I figured, why don't we just delete it and see what happens? So that's just what I did, and I pushed it up to our CI, and the build passed.
00:06:09.900 How could this be? The reality is that things are, I mean, if you especially saw Eileen's talk yesterday, a little different in the way we use Rails at GitHub. GitHub is a Rails monolith that just turned 11 years old.
00:06:36.660 So let's talk about scale. We have over 200 controllers, not including our API. We have over 550 models, not including concerns, of which there are about 1500, and over 3,700 views. How might this scale affect our approach to testing our views? Right now, our main way of exercising our view code is through controller tests set up to render ours.
00:07:02.999 In our test suite, it takes six seconds to run one of these tests, not including any setup. Setting another way, that's one minute to run a suite of ten cases. Does anyone here know how long the Jeopardy theme song is? It's 30 seconds. There we go! So imagine listening to it twice every time you want to run a suite of ten cases. You know, at our scale, this just isn't sustainable.
00:07:41.719 I believe this problem is a symptom of several shortcomings in the Rails view layer. When I asked my local Ruby group this question, the number one response was data flow. A common data flow error we're probably all familiar with is the good old N+1 query, where we accidentally generate an expensive query in a view.
00:08:02.249 Our example code also has some data flow issues, so for a pull request and issue, what attributes do we need from each object? If these are Active Record objects, we'd be fetching their entire set of attributes when we may in fact only need one or two for each object. In addition, it's unclear where the pull request and issue variables are coming from, making it difficult to reuse this partial with any amount of confidence.
00:08:36.990 Another problem is that unit testing views is a common practice in Rails. Rails encourages us to test our views through integration and system tests, which are expensive. This is especially painful for partials like our issue badge, as they often end up being tested for each of the views they're included in, which leads to duplication of tests and really cheapens the benefit of reusing a partial in the first place.
00:09:02.670 Another problem is measuring code coverage. Neither SimpleCov nor Coveralls support view code. This, combined with the friction of writing tests, puts our views in a real blind spot compared to the rest of our code. Another weakness is the lack of a method signature. Unlike a method declaration on an object, views do not expose the values they are expected to receive.
00:09:30.060 So let's go back to our example. What data does this view need to render? Does it need a pull request? Does it need an issue? Should I be able to pass in both? What about neither? Are these values passed in as locals, or did they come from a helper? The reality is that our views regularly fail even the most basic standards of code quality we expect out of our Ruby classes.
00:10:13.770 So let's take another look at that example code. If this was a method on a class, what aspects might we object to in a code review? Besides it being a super-long method, I can think of a couple: where is 'arctic undefined'? Where does this class attribute value come from? It kind of feels like a magic string. Where are the pull requests and issues coming from?
00:10:45.570 The reality is that we regularly do things in our templates that we would never do in a Ruby class. So to recap: Rails views are difficult to test, and those tests are impossible to audit with code coverage tools, preventing us from knowing how thorough they are. They make it difficult to reason about data flow, have implicit method signatures, and often fail basic Ruby code standards.
00:11:17.410 The reality is that the existing Rails View layer is a second-class citizen these days. With all these shortcomings, perhaps it isn't much of a surprise that a new way of building views has taken hold in our community: React. React is all about components. A component encapsulates a piece of user interface, making it easy to reuse.
00:11:50.510 Here's one way of writing 'Hello World' in a React component. React components, at a minimum, implement a render method that returns HTML. The arguments passed to a component are assigned to the props, which are accessible within methods on the component. Here's an example of what the issue badge might look like as a React component. Like our template, the component renders an icon and label wrapped in a state-specific CSS class.
00:12:31.360 Another dimension of the React architecture is types. The PropTypes library allows React components to express some expectations about the data they receive. In this case, we are expecting an issue with the isClosed boolean to always be provided and a pull request to sometimes be provided, if so with the isClosed merged and isDraft booleans.
00:13:09.210 What's great about this is that we can then reference the isClosed boolean on issue without fear, as our type check will guarantee that it is present. Another advantage of React is how it simplifies data flow by passing values into views instead of objects. React encourages us to write functions without side effects. Another great thing about React is how easily components can be tested in isolation.
00:13:45.990 So let's have a look at a sample component test. Here's an example test that renders the component directly and asserts against the output. What's great is that this test runs without touching the database or the controller layer, which means that it's really, really fast.
00:14:22.780 So to recap: React has components that render HTML, types that give us confidence in our inputs, simplify data flow, and lightweight testing in isolation. Which is really too bad, because it's not compatible with our progressive enhancement architecture that we use at GitHub. But what if there is a way we can incorporate some of the benefits of React into Rails?
00:14:56.530 Before we start doing some refactoring, let's write some tests because we want to make sure that we're not going to break anything. So what might it look like to test our view? In each case, we're doing three things: study the classname, icon, and label. To start, let's add some traditional controller tests for each state. We'll start with the test for the open issue badge and confirm that we have the correct class name, icon, and label.
00:15:31.500 Now we have test coverage, so let's delete the view like I did earlier and see what happens. We have failing tests, which means we can actually do some refactoring. What might a component look like in the Rails world? I think it would make sense to make it a class, like everything else in Ruby. So let’s call it Badge inside the Issues module. How might we call it in our view? The Rails way would be to use the existing render syntax.
00:16:04.690 So let's see if we can get our first test to pass. First, we'll add a method to our component that returns the open issue badge from our partial. We'll call it HTML. As render is a loaded word in Action View, we'll run our test.
00:16:37.170 This is interesting; it looks like Action View's render doesn't like being passed our component. Imagine that. So let's teach it how to handle it now. Short of forking Rails and changing the original definition of Action View's base render, let's write a monkey patch.
00:17:03.060 Not kidding! Our monkey patch will redefine render so that when you pass in our component, it calls our component's HTML method. Let's run our test again. 'Undefined method octa con?' That's an interesting error. Remember our code review comment about not knowing where that octagon method came from? Now our code is asking us the same question.
00:17:31.050 So back in our component, let's tell it where to find it and run our test again. It looks like it can't find the CSS we're looking for. I wonder what our component is rendering at this point? It looks a little something like this: escaped HTML.
00:17:59.420 So it might be tempting to use something like HTML safe here. However, I think it might be a better idea to just try and reuse the Rails rendering pipeline and all the safety guarantees it gives us. First, let's move our template into a method called template, and then in our HTML method, we'll run our template through Action View's ERB template handler.
00:18:30.510 This effectively mirrors how regular Rails templates are compiled and executed in the view layer today. So let's run our tests again, and we're green! Time to ship it, right? But let's keep moving along.
00:19:02.310 The next test is for a closed issue, which should have a red background, a closed icon, and a close label. Let's run it. They can't find the red CSS class as we have not handled this case yet.
00:19:35.260 So let's go back to our template that renders the component. We're already passing an issue here, but we aren't doing anything with it yet. Let's change that. First, we're going to need to update our monkey patch to take the arguments we're passing into render and pass them into an initializer on our component.
00:20:02.720 Next, we'll need to define that initializer on our component. In this case, if you can remember back to the slide about the data model, we're going to need to let pull requests be nil—after all, not all issues have pull requests.
00:20:27.840 Now that we have an issue, we can render and reference it in our template. So if we run our new test, it's green! Something interesting just happened there: we gave ourselves an interface for our view, which means no more implicit arguments.
00:20:50.190 Just like that, we're making even more progress on our code review. So next, let's take the rest of our partial and drop it into our component to see how the rest of the controller tests do. We're still green, but something doesn’t feel quite right about this template.
00:21:17.620 The first two-thirds handle various pull request states, while the last third handles issue state. It really seems that we have two components here, not one. Since the view that calls our component knows whether it is dealing with the pull request or an issue, how about we split this out into two components and let the view pick which one to use?
00:21:41.860 That’s not so bad. Based on whether the issue has a pull request, we can render either the pull request badge or the issue badge. So back at our monkey patch, we're going to need to do a couple of things. We need to update our conditional to look for both components.
00:22:02.760 But let's check on our tests: they're still green! So let's go back to our code review. Remember that comment about magic strings? Both our issue badge and our pull request badge are rendering the same state UI element from our design system. So why don’t we make that a component?
00:22:45.410 Our state UI element only has one option: color. If it was a component, that would be our single argument. Let's build it. We'll call it State within the Primer module, and then we’ll also need to update our monkey patch to handle this new component.
00:23:10.230 You know, this is getting a little weird—perhaps we're missing an abstraction here. What we're really trying to say with this line of code is whether am I dealing with one of these new components? I think it might be time for a parent class, and we’ll call it ActionViewComponent.
00:23:32.150 Let's take our new parent class and update our existing components to inherit from it. Then we can simplify the conditional in our monkey patch to check just if the argument is a subclass of ActionViewComponent.
00:24:10.090 Now that we have a parent class for our components, we can also reduce duplication. An easy candidate is our HTML method, which doesn't have any component-specific logic, so let's move that to ActionViewComponent.
00:24:52.690 Let's check in on our tests: they’re still green! Back to building our new component: this one's a little different as we’re passing content as a block. So let's start by writing a test.
00:25:11.050 Now, we could probably just rely on our existing controller tests for this, but wouldn’t it be nice if we could test this new nested component directly by itself? In React, our tests were able to render our component directly and then assert against the resulting HTML.
00:25:39.800 Ideally, we'd be able to do the same in Rails: render the component directly and then assert against the resulting HTML. All we would need is a way to render our template in line, which is easy to do with the application controller. We can render our template in the same code path as a normal view and then parse the result in Nokogiri, making those assertions a lot easier.
00:26:13.520 So let's run our tests. Now, it looks like something that expects a class is receiving a hash. It looks like our test helper is to blame here for passing a hash into the render.
00:26:38.860 As it turns out, ActionViews' render method accepts a couple of different types of arguments. So we're going to need to go back to our monkey patch and update our conditional to ensure we're dealing with a class.
00:27:04.580 Then we’ll rerun our tests. That’s a lot closer to what we’re looking for: we are expecting our content to be rendered, but we just got an empty string.
00:27:30.910 Let's think about how we might make this work. When we’re passing content into our component, what we're effectively saying is render this block in the context of the current view and then wrap the result in the component.
00:27:53.730 So first, we're going to need to update our monkey patch to accept a block argument. Then we're going to need to update our render step: first instantiate the component, and then if the block has been passed, render it in the context of the current view using Action View's capture helper.
00:28:12.600 We’ll assign the result to an accessor on the component. At that point, our component will know about the content, so we can render it. Now that we’re expecting our components to have this content accessor, let’s go back to ActionViewComponent and declare it there.
00:28:43.830 Then it’s just a matter of taking our component and updating the template to render the value of that content accessor. So let’s check in on our test, and we’re back to green!
00:29:01.270 But now that we have content, what about setting the color? So let’s start by adding a color argument to the initializer. But what values do we need to handle? If we look at our design system documentation, it looks like we can specify three: green, red, and purple. Otherwise, the component defaults to gray, which you've probably seen as our new feature draft pull requests.
00:29:17.960 So let's go back to our component and capture those relationships in a constant. This gives us a clear mapping between the default value of not applying a CSS class and the color values with their respective CSS classes.
00:29:40.150 What’s great is that the keys of our hash also represent the entirety of the values we should allow for the color argument. So how can we enforce this in our component? Let's start with the test. We'll assert that when passing in a color that we aren't expecting, like chartreuse, an error will be raised.
00:30:02.430 Then it will be raised with a message that should be suspiciously familiar to all of you. So let's run it and make sure it fails. How might we ensure that color is one of the expected values? We're in Rails, so this is a solved problem. It's called Active Model validations.
00:30:23.810 Back in our component, we can use an inclusion validation to check that color is one of the keys on our constant. To make this work, we’ll also need to define an attribute reader in ActionViewComponent. We’ll need to include ActiveModel validations, and that’s all that’s left.
00:30:42.440 Now let's head back to our monkey patch and add a step to validate our component before we render it. Let’s run our test again: we’re back to green!
00:31:04.140 Now let’s add a test to make sure we’re setting the right CSS class based on the color and that it fails. Previously, we just had the CSS class hard-coded in our template, but now that we can be sure that color is one of the keys in our hash, we can safely use the hash to look up the correct CSS class.
00:31:22.230 Now we're back to green, but let's take another look at that documentation. I think we might have missed something here: we're supposed to have a title attribute. The reality is that for most of the components in our design system, CSS isn’t the only interface. There are many things we must take into account, like accessibility reasons.
00:31:43.670 This is something our original partial never accounted for. So let's make sure that doesn't happen again, and we’ll do that with the test that passes in an empty title and expects a validation error.
00:32:04.600 Then it's just a matter of adding a presence validation for the title attribute, and we’re right back to green! Now let's see how our controller tests are doing. 'Missing keyword title?' I think we might have just caught a regression.
00:32:33.130 We never updated our consumers of this new Primer State component to pass in that new required title argument. So let's add the title attribute, and we’re back to green!
00:32:55.140 When it comes to data flow, we were mainly concerned with our views unintentionally querying our database. But what if we could avoid passing an Active Record object altogether? That would eliminate that risk, right? So let's start with issue badge.
00:33:17.540 Right now, we're passing in an issue object, which is from Active Record, but the only thing we're doing with it is calling the close predicate method. As you can probably imagine, our issue model's interface is much more than just this one method, yet we're passing the entire object just to get this one value.
00:33:37.650 So if we look at the very abbreviated implementation of issue, you can see that this close predicate method is just checking whether the value of state is closed.
00:33:56.400 So what might our component look like if we passed in the state value instead of the whole issue object? First, we need to update the initializer to accept the state value instead of the issue object, and add validations for the possible values of state.
00:34:18.230 But! Looking at our template, what if we could extract each branch of this to just be derived from the value of state? We could clearly express the relationship between the state and the combination of color, icon name, and label.
00:34:44.730 We can take our template and extract the values from the constant instead of having nearly duplicate branches of our template. Let's run our tests: and we're still green! But what about our pull request component? How might we decouple it from Active Record?
00:35:07.490 Looking at the template, we're relying on three predicate methods: merged, closed, and draft. So can we pass a state value like we did for the issue component?
00:35:28.830 Looking at another abbreviated model, things aren't so simple as they were for the issue model. While we do have a state value, whether the pull request is a draft or not is independent of that value and is just stored as a boolean in our schema.
00:35:51.480 So this means we're going to need both these values to render our component correctly. Let's start with some tests, and what's lucky for us is that we have an easy way to unit test our components.
00:36:15.370 In this first test, we’ll assert that when you pass in the state and the ‘isDraft’ values, we render the correct label, title attribute, and icon. Let’s run them.
00:36:34.240 So, it looks like our component is still expecting the old argument. Let’s update it. First, we’ll need to update the initializer to accept the state and isDraft values instead of the pull request object.
00:36:51.560 Then let’s take our template and extract the title color, arctic our name, and label into methods, and I’ll spare you the details of those right now.
00:37:14.770 It's interesting here. If you take a good look at this component, it's really similar to our React mock-up. It's almost uncanny!