DEV Community

Cover image for Integrating React into ASP.NET Core using Razzle with all the goodies like SSR, routing, code splitting, and HMR – Part 2/2
Peter Ruttkay-Nedecký
Peter Ruttkay-Nedecký

Posted on • Originally published at nede.dev

Integrating React into ASP.NET Core using Razzle with all the goodies like SSR, routing, code splitting, and HMR – Part 2/2

In the first part of this article, we have created a React application with server-side rendering (SSR) in ASP.NET Core. We did not use the well-known library ReactJS.NET but instead went a different way that gives us greater flexibility. We helped ourselves with a tool called Razzle and used Javascript.NodeJS to call NodeJS from ASP.NET Core.

In this part, we will add data loading to both the client and the server-side. We will look into code splitting and wrap up with a deployable package of our application.

Data loading

We need to be able to access data on three occasions.

  • When the request first comes to the application, we need to return HTML containing a fully rendered page. For that, we need to provide data into our React application while rendering it on the server side using renderToString.

  • During hydration, we must render the same HTML on the client-side. That means we need the data again.

  • Finally, when we do a client-side routing, we need to load the data from the server using AJAX.

Let’s make a simple in-memory data provider that we will use in our HeroController.

public class HeroDb : IHeroDb
{
    private readonly Hero[] _items = new[]
    {
        new Hero{
            Id= 1,
            Name= "Luke Skywalker",
            Height= 172,
            Mass= 77,
            BirthYear= "19BBY",
        },
        ...
    };

    public Hero[] GetAll()=>_items;
    public Hero Get(int id)=>_items.SingleOrDefault(h => h.Id == id);
}
Enter fullscreen mode Exit fullscreen mode

Client-side data loading

First, we will focus on client-side routing, which is going to be the most straightforward part. We just need to be able to retrieve the data using AJAX. To simplify things, we will always load all the data for the current page using a single AJAX call.

Let’s add two additional actions to our controller.

public class HeroController : Controller
{

    [Route("data/")]
    public IActionResult IndexData() => Ok(_db.GetAll());

    [Route("/data/{id:int}")]
    public IActionResult DetailData(int id) => Ok(_db.Get(id));
}
Enter fullscreen mode Exit fullscreen mode

Both actions correspond to actions that we created last time for SSR. Each has a "data/" prefix in its URL. This way, we have a convention for accessing page data based on the URL of the current page without any additional configuration. In a real-life application, we would merge data and non-data actions to a single action to prevent duplication. We could achieve it, for instance, by using URL rewrite, but that is outside of the scope of this article.

To keep the data loading on the client side in one place, we are going to introduce a simple higher-order component.

const page = (WrappedComponent) =>
  ({ staticContext }) => {
    const location = useLocation();

    if (!staticContext) {
      useEffect(() => {
        fetch(`data${location.pathname}`)
          .then(r => r.json())
          .then(setPageData);
      }, [location]);
    }

    const [pageData, setPageData] = useState(null);

    return (
      pageData && <WrappedComponent pageData={pageData}></WrappedComponent>
    );
  };
Enter fullscreen mode Exit fullscreen mode

We are retrieving the data using fetch API. The useLocation hook provided by React router is helping us to construct the URL. Note, that we are ignoring query string because we are not using it in our examples.

As you can see, we are retrieving data only if the staticContext is not set, which means that we are rendering the application on the client-side. We will use another way for the server-side later.
We are fetching data in an effect hook with location dependency to update data each time the location changes due to client-side routing.
In production code, we would also add cancelation of old requests and error handling, but we will omit it here to keep the example simple.

Thanks to page component, we can now easily add data into the HeroList and HeroDetail components.

const HeroList = ({ pageData }) => (
  <div>
    <h2>List of heroes</h2>
    <div>
      <ul>
        {pageData.map(hero => (
          <li key={hero.id}>
            <Link to={`/${hero.id}`}>{hero.name}</Link>
          </li>
        ))}
      </ul>
    </div>
  </div>
);
export default page(HeroList);
Enter fullscreen mode Exit fullscreen mode
const HeroDetail = ({ pageData }) => (
  <div>
    <h2>{pageData.name}</h2>
    <div>
      Height: {pageData.height}
    </div>
    <div>
      Mass: {pageData.mass}
    </div>
    <div>
      Year of birth: {pageData.birthYear}
    </div>
    <div>
      <Link to="/">Back to list</Link>
    </div>
  </div>
);
export default page(HeroDetail);
Enter fullscreen mode Exit fullscreen mode

Server-side data loading and hydration

To add data loading on the server-side, we have to make small adjustments to SsrResult and RenderService classes.

public class SsrResult : IActionResult
{
    ...
    private readonly object _data;
    public SsrResult(string url, object data)
    {
        ...
        _data = data;
    }
    public async Task ExecuteResultAsync(ActionContext context)
    {
        ...
        var renderResult = await renderService.RenderAsync(_url, _data);
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode
public class RenderService : IRenderService
{
    ...
    public Task<string> RenderAsync(string url, object data) => 
        _nodeJSService.InvokeFromFileAsync<string>(_serverJsPath, 
            args: new object[] { url, data });
}
Enter fullscreen mode Exit fullscreen mode
public class HeroController : Controller
{
    ...
    [Route("/")]
    public IActionResult Index() => new SsrResult("/", _db.GetAll());
    [Route("/{id:int}")]
    public IActionResult Detail(int id) => new SsrResult("/:id", _db.Get(id));
    ...
}
Enter fullscreen mode Exit fullscreen mode

We are receiving data in the SsrResult constructor and passing them straight into server.js through RenderService and INodeJSService.

We can now use the data in server.js to render the application.

const server = (cb, url, data) => {
  const context = { data };
  const markup = renderToString(
    <StaticRouter context={context} location={url}>
      <App />
    </StaticRouter>
  );

    ...

  cb(null, `<!doctype html>
      <html lang="">
      <head>
          ...
          <script>window.__ROUTE_DATA__=${JSON.stringify(data)}</script>
          ...
      </head>
      ...
    </html>`);
}
Enter fullscreen mode Exit fullscreen mode

We are passing received data into the context of the StaticRouter and thus making it available to our page component. By using an inline script, we are assuring that we can access the data also during hydration.

We are ready to take advantage of the data in our page higher-order component during SSR and hydration.

const page = (WrappedComponent) =>
  ({ staticContext }) => {
    const location = useLocation();

    let initData = null;
    if (staticContext) {
      initData = staticContext.data;
    } else if (window.__ROUTE_DATA__) {
      initData = window.__ROUTE_DATA__;
      delete window.__ROUTE_DATA_;
    }

    if (!staticContext) {
      useEffect(() => {
        if (!initData) {
          fetch(`data${location.pathname}`)
        ...
Enter fullscreen mode Exit fullscreen mode

We are retrieving the data from the staticContext (during SSR) or the __ROUTE_DATA__ window field (during hydratation). You might have noticed that we are clearing the __ROUTE_DATA__ field after filling the initData variable. This way, we ensure that the initial data is used only during hydration and not for another page during client routing.

Let’s check the browser. When we open the https://localhost:5000/4 URL, we can see that the initial request contains fully rendered HTML with all the data.

Rendered HTML

When we navigate to the list using the “Back to list” link, we can see that only an AJAX call was executed.

AJAX call response

Code splitting

We have fully functional SSR now. It's time to add a cool feature that is currently not supported by ReactJS.NET - code splitting. Code splitting enables us to split our scripts into multiple chunks and lazy load them only when necessary. That means faster load times for our users.

We are going to use the Loadable Components library that, unlike React.lazy, also supports SSR. Thankfully, Razzle has a nice example for Loadable Components, so our work will be rather easy.

First, we need to install a few dependencies.

npm i @loadable/component @loadable/server -d
npm i @loadable/babel-plugin @loadable/webpack-plugin -D
Enter fullscreen mode Exit fullscreen mode

Now we can update razzle.config.js to include the installed Loadable Webpack plugin using the following code.

if (target === "web") {
    const filename = path.resolve(__dirname, "build");
    config.plugins.push(
        new LoadableWebpackPlugin({
            outputAsset: false,
            writeToDisk: { filename },
        })
    );
}
Enter fullscreen mode Exit fullscreen mode

Loadable Components also requires a Babel plugin (@loadable/babel-plugin) for SSR to function properly. Razzle supports modifications to Babel config through a ".babelrc" file in the folder where the razzle.config.js is. Razzle will then automatically pick it up during its initialization.

{
    "presets": [
        "razzle/babel"
    ],
    "plugins": [
        "@loadable/babel-plugin"
    ]
}
Enter fullscreen mode Exit fullscreen mode

We are using the razzle/babel preset, which will give us all the defaults provided by Razzle, so we don’t have to configure them manually.

Next, we need add chunk extractor from Loadable Components into server.js file.

const server = (cb, url, data) => {
  const context = { data };
  const extractor = new ChunkExtractor({
    statsFile: path.resolve(__dirname, 'loadable-stats.json'),
    entrypoints: ['client'],
  });
  const markup = renderToString(
    <StaticRouter context={context} location={url}>
      <ChunkExtractorManager extractor={extractor}>
        <App />
      </ChunkExtractorManager>
    </StaticRouter>
  );

  const scriptTags = extractor.getScriptTags();
  const linkTags = extractor.getLinkTags();
  const styleTags = extractor.getStyleTags();

  cb(null, `<!doctype html>
      <html lang="">
      <head>
          <meta http-equiv="X-UA-Compatible" content="IE=edge" />
          <meta charset="utf-8" />
          <title>Welcome to Razzle</title>
          <meta name="viewport" content="width=device-width, initial-scale=1">
          <script>window.__ROUTE_DATA__=${JSON.stringify(data)}</script>
          ${linkTags}
          ${styleTags}
      </head>
      <body>
          <div id="root">${markup}</div>
          ${scriptTags}
      </body>
    </html>`);
}
Enter fullscreen mode Exit fullscreen mode

Notice that we have also replaced assets in the HTML template with the ones that come from the chunk extractor.

We want to lazy load both our pages, so we need to wrap their imports in the App.js file with the loadable function provided by Loadable Components.

const HeroList = loadable(() => import('./HeroList'))
const HeroDetail = loadable(() => import('./HeroDetail'))
Enter fullscreen mode Exit fullscreen mode

To wait for all asynchronously loaded scripts required to render the application, we must also wrap the hydrate call in client.js with the loadableReady function.

loadableReady().then(() => {
  hydrate(
    ...
  );
});
Enter fullscreen mode Exit fullscreen mode

With that, we finished the integration of the code splitting into our application. Notice that we did not have to do anything special just because we are using ASP.NET Core as our backend, which is awesome.

Publishing the application

In the previous part of the article, we have bootstrapped our application using the standard React template provided by ASP.NET Core. Thanks to this, the publish profile was created for us, and we don’t need to change a single line in it. If we open the csproj file, we can see that the PublishRunWebpack target runs

npm install
Enter fullscreen mode Exit fullscreen mode

and then

npm run build
Enter fullscreen mode Exit fullscreen mode

The build npm script was created in package.json automatically by create-razzle-app when we bootstrapped the client side of our application.

The only thing that we need to do is a small modification of the Webpack configuration. Razzle is using webpack-node-externals to exclude all node_module packages from the server bundle. It makes sense for a NodeJS backend, but in our case, it would just make things harder during deploy. We would need to copy package.json, package-lock.json, and install packages on the destination server. It is much easier for us to let Webpack bundle all the dependencies into the resulting package – we are not using any dependency that couldn’t be bundled like this.

Let’s do a final modification to razzle.config.js.

if (dev) {
    ...
} else {
    if (target === 'node') {
        config.externals = [];
    }
}
Enter fullscreen mode Exit fullscreen mode

You can read more about Webpack externals in the official documentation of Webpack.

And we are done. Execute the publish using the following command.

dotnet publish
Enter fullscreen mode Exit fullscreen mode

The result is a fully functional package of our application.

Conclusion

This concludes our SSR React + ASP.NET Core backend integration. What I personally really like about this way is that we are free to use any React library that requires special handling for the SSR to function. We can use cool stuff like code splitting and most likely anything that will Webpack provide in the future because we have nicely decoupled the ASP.NET Core backend and the Webpack/React part.

You can access the complete code of the example application here https://github.com/pruttned/AspNetCoreReactRazzleExample .

Top comments (0)