DEV Community

Cover image for How to Do a Device Based Code Split in React
Miroslav Nikolov
Miroslav Nikolov

Posted on • Edited on • Originally published at webup.org

How to Do a Device Based Code Split in React

This article is a mix of arguments, reality checks and a code solution at the end. Its focus: device (touch/desktop) driven code split in React with no backend.

Often the road leading to an actual implementation is long and bumpy - priorities, design, budget, colleagues with their own views, talking in different languages. These obstacles are challenging and usually take more energy to deal with than just coding. For that reason they deserve a separate preface here.

Jump to the code section, if this is what you are looking for, otherwise let's continue.

It would be helpful if you already know what code splitting is. If not yet, the "Code Splitting" writeup in the React docs is a good start.


Reality Check

Many companies today prefer to build their web apps/sites targeting both touch and desktop devices, but would rather not invest in a separate mobile app.

Chiefs may not admit it, but the reasons spin around:

  1. Building for the browser is fast and cheap.
  2. No need to involve the backend.
  3. Prizing "mobile first", but don't really align with that principle.
  4. Technical impediments to deliver a mobile app to the store.
  5. No budget.

Working in the browser is fast and reliable. There are many static site generators (Gatsby, Nextjs, Docusaurus) to support website creation with no backend knowledge required. Jamstack principles and tools make production deployments of a product easier than ever. Such tools are capable of bringing the "mobile first" concept to life, though it still remains wishful thinking.

At the same time publishing a standalone mobile app to some app stores may turn into a nightmare. Read about the Hey saga fx. In contrast, javascript devs can quickly mockup a mobile version with the help of Chrome tools, so why hire an iOS/Android guy?

All valid points and to add more, often you as a frontend professional won't get the chance to influence the final decision (especially in big companies). It is to be taken by product, marketing or finance teams.

Native app or web app... Let's assume a decision is taken and you are left with no choice - a web app must be delivered (for desktop and mobile users).


If You Must Code Split

Splitting react apps touch/desktop wise can be tricky if you have to do it in the frontend.

Things to be considered:

  • 1️⃣ consider touch and desktop devices (when to serve each app)
  • 2️⃣ decide on the split starting point (where in the code)
  • 3️⃣ import only app specific components (how to implement it)

An answer to these three questions is important since maintainability, time, team motivation and other aspects very much depend on it.


When a Device Is Considered Touch 1️⃣

Usually you modify component's css to account for mobile devices.

Perhaps the following

.TopBar {
  height: 60px;
  background-color: #fff;
  ...
}

/* Mobile */
@media (max-width: 768px) {
  .TopBar {
    height: 100px;
    background-color: #ccc;
    ...
  }
}
Enter fullscreen mode Exit fullscreen mode

works well for you most of the time. Same component, but with different appearance based on browser's width. There is no problem with this approach and very often it is enough. Now one may argue that max-width: 768px is sufficient to properly tell if a user is on a mobile device. Probably not. May be something like that is more accurate:

@media (pointer: coarse) and (hover: none) {
  ...
}
Enter fullscreen mode Exit fullscreen mode

You can read more about interaction media features and their potential to determine device capabilities. Consider it when deciding on the criteria for serving your mobile web app.


Challenges arise when your company starts getting more serious about mobile users ("mobile first"). This could happen due to a separate strong design/UX and product teams being formed. In this reality your desktop and mobile websites/apps may end up drastically different. Business logic, pages, interactions and overall appearance are now unalike. Two independent versions of the same software.

How does that translate in the React's language?

For sure you won't be able to reuse every single component in both apps (touch and desktop). Same components/pages will require different data sets and behave non-identically (javascript logic). Others will be completely unique per app. In that case css adjustments as the one above may no longer be sufficient. Interactions and data (javascript) need to be considered along with styling (css).

This is where a proper split in the frontend must be done and it can't reside in your .css files alone.


Where to Split the App 2️⃣

It really depends. You have a few options considering requirements and design. One is to split the app in its root. Maybe you have PageRouter.js or just App.js where page components are rendered based on the URL path. Second option - split individual components. It is a good choice if pages for mobile and desktop are the same (or very similar), but some child components differ. You can also pick the third option of using media queries in the css.

Split in the App's Root

This approach makes sense if your mobile and desktop apps are very different - separate pages, behavior, data and business logic in components.

Let's say there is a product details page (<ProductDetails />) on touch which doesn't exist in your desktop site. It displays detailed product information that otherwise would be part of <Products /> when viewing on PC. On a phone, though, it might be too "noisy" to present so much data in a single page.

-- src
   |-- components
   |-- pages
   |   |-- touch
   |   |   |-- Products.js
   |   |   |-- ProductDetails.js
   |   |-- desktop
   |   |   |-- Products.js
   |   |-- common
   |       |-- Checkout.js
   |-- App.js
Enter fullscreen mode Exit fullscreen mode

See a working example in Codesandbox.

Why is this structure OK?

  • More control

You can look at /touch and /desktop folders as two separate apps, allowing for full control over their content.

  • Easier maintenance

Most pages in your app will be common - same names component-wise, but implementing app specific logic, which is great for maintenance.

  • Bug fixing in isolation

Having a bug in the products page on touch tells you that the cause is probably in touch/Products.js. Fixing it there ensures your desktop page won't be affected.

  • Less side effects

Few more buttons for mobile or a dropdown on desktop? You can feel more comfortable implementing feature requests like that next time.

  • Adequate team collaboration

Implementing a products page means you have to do it for each app (two components). With the folder split above, it's easy to divide the work within the team without stepping on each other's toes.

Split on Component Level

Root level code split is often supplemented by splitting the /components folder in a similar way. On the other hand, sometimes your desktop and mobile apps won't be very different. Only a few components deep in the tree may have an unalike data model or behavior. If you find yourself in any of these cases it might be useful to do a split per component.

-- src
   |-- components
   |   |-- touch
   |   |   |-- TopBar.js
   |   |   |-- TopBar.css
   |   |-- desktop
   |   |   |-- TopBar.js
   |   |   |-- TopBar.css
   |   |-- common
   |       |-- Footer.js
   |       |-- Footer.css
   |-- pages
   |-- App.js
Enter fullscreen mode Exit fullscreen mode

<TopBar /> component has some data/behavior differences that require you to implement it separately for each app. In the same time /common folder still contains all shared components.

You can see how that is done for /components in products page example.

Why is this structure OK?

Adding to the pros of the previous section you will have less code to maintain, since only a few components may require a split. Reusing app specific and shared components is also going to be straightforward.

import ProductDescription from "../../components/desktop/ProductDescription";

export default function Products() {
  ...
}
Enter fullscreen mode Exit fullscreen mode

pages/desktop/Products imports only components from components/desktop.

Components with Styling Differences

Should you create two copies of a component if it contains the same logic, but differs in styling? Looks like it should be shared and placed in the /common folder, but at the same time its css will need the good old media query approach.

@media (max-width: 768px) { ... }

/* OR */

@media (pointer: coarse) and (hover: none) { ... }
Enter fullscreen mode Exit fullscreen mode

That looks ok. Is it the best thing you can do, though? What if the logic detecting mobile capabilities changes? Should you change it everywhere? This is not optimal.

Ok, what to do?

Ideally the logic for detecting touch devices should be central for the app. Getting a desktop or mobile component to render should be a matter of simply tweaking a prop.

Imagine this structure:

-- src
   |-- components
   |   |-- touch
   |   |   |-- TopBar.js
   |   |   |-- TopBar.css
   |   |-- desktop
   |   |   |-- TopBar.js
   |   |   |-- TopBar.css
   |   |-- common
   |       |-- TopBarLinks.js
   |       |-- TopBarLinks.css
   |-- pages
   |-- App.js
Enter fullscreen mode Exit fullscreen mode

<TopBarLinks /> is a shared component and may have some visual diffs. In its css this is addressed with a class.

.TopBarLinks { ... }         /* Desktop */
.TopBarLinks.touch { ... }   /* Mobile */
Enter fullscreen mode Exit fullscreen mode

Then it is used both in desktop/TopBar and touch/TopBar:

// desktop/TopBar.js
export const TopBar = () => (
  <div className="TopBar">
    <img alt="Logo" src="../../assets/logo.png" />
    <TopBarLinks />
  </div>
);
Enter fullscreen mode Exit fullscreen mode

and

// touch/TopBar.js
export const TopBar = () => (
  <div className="TopBar">
    <img alt="Logo" src="../../assets/logo.png" />
    <TopBarLinks touch />
  </div>
);
Enter fullscreen mode Exit fullscreen mode

That's it. This is how you can render shared components with visual diffs. As a result the css file is cleaner and independent of the device detection logic.

Enough said on the possibilities for organizing the codebase. Now, how to glue things together.


Load Components on Demand 3️⃣

No matter where the split resides in - application root or individual components, or perhaps both - its implementation is going to be the same. Ultimately the pages from all earlier examples are also components.

The task is to load only desktop OR touch related code in the browser. Loading the whole bundle (all components), but using (rendering) only device specific slices may work, but it's not optimal. A proper implementation requires you to use dynamic import().

React docs tell you that Suspense relies on that principle underneath and will probably do the job. You could also base your solution on loadable-components library. For the sake of simplicity and to cover the specific use case of touch/desktop based split, let's further focus on a plain solution.

Conditionally Import and Render Components

I personally imagine the following in the application root (App.js):

import Import from "./Import";

function App() {
  return (
    <div className="App">
      <h1>Product page</h1>
      <Import
        touch={() => import("./touch/Products")}
        desktop={() => import("./desktop/Products")}
      >
        {Product => <Product />}
      </Import>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

See it in the example Codesandbox app.

The <Import /> component (you can name it differently) accepts two props - desktop and touch. They expect a function returning a dynamic import call. In the example above there are two independent <Product /> page components that you may want to import/render conditionally.

The third prop is a children function that does the actual rendering. An obvious benefit of using render prop function here is the opportunity to explicitly pass any props to your component if needed.

{Product =>
  <Product
    title={product.title}
    description={product.description}
  />
}
Enter fullscreen mode Exit fullscreen mode

Implementation Details

What will Import do internally is to: evaluate which component to load and pass it down as an argument to the render prop function.

Basic implementation may look like:

// Detect touch enabled devices based on interaction media features
// Not supported in IE11, in which case isMobile will be 'false'
const isMobile =
  window.matchMedia("(pointer: coarse) and (hover: none)").matches;

export function Import({ touch, desktop, children }) {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    // Assign a callback with an import() call
    const importCallback = isMobile ? touch : desktop;

    // Executes the 'import()' call that returns a promise with
    // component details passed as an argument
    importCallback().then(componentDetails => {
      // Set the import data in the local state
      setComponent(componentDetails);
    });
  }, [desktop, touch]);

  // The actual component is assigned to the 'default' prop
  return children(Component ? Component.default : () => null);
}
Enter fullscreen mode Exit fullscreen mode

More on Import and its usage - check the app context.

Some notes:

  1. window.matchMedia("(pointer: coarse) and (hover: none)") - you can use any other mechanism for detecting touch capabilities here. Going a step further, isMobile may come from the store instead (if you are using redux, mobx or other global state management mechanism).

  2. importCallback().then(componentDetails) - the actual component is set in componentDetails.default and you have to export it using default export (export default function Products()).

  3. Finally, imported data is set to the local state and your component passed down to the children function for rendering.

Using import() requires some prerequisites to allow for proper parsing and dividing the final bundle in parts. You may need to additionally set these up.

Webpack Config

For the split to work there are some adjustments in the webpack config file to be made. An example config by Dan Abramov can be found on github. If you are using Create React App that is done by default.

module.exports = {
  entry: {
    main: './src/App.js',
  },
  output: {
    filename: "bundle.js",
    chunkFilename: "chunk.[id].js",
    path: './dist',
    publicPath: 'dist/'
  }
};
Enter fullscreen mode Exit fullscreen mode

Babel Plugin

If you are using Babel the @babel/plugin-syntax-dynamic-import plugin is required in order to properly parse dynamic imports.

Eslint Config

eslint-plugin-import is also required to support export/import syntax. Don't forget to update your eslint config file:

{
  parser: "babel-eslint",
  plugins: ["import"]
  ...
}
Enter fullscreen mode Exit fullscreen mode

Again code splitting is supported by default with Create React App and you can skip the config steps in that case.


Final Words

Check the full code implementation in Codesandbox for details on device based code splitting.

I would like to wrap up by sharing my own motivation for having app structure like the one described. It may not be your case, but my observations show a common mindset especially in big corps where a clear separation between product, backend and frontend is in place.

In that reality it's much easier (and often the only thing you can do) to overcome process issues with a tech solution, instead of trying to change people.

Here is an example: you know that backend will deliver the API in a week, but you also know that you can deliver the UI today. Waiting one week for the backend? The slow backend delivery might be due to organizational issues. The tech solution in that case is to mock the payload and deliver to QA and Product teams early.

The same motive plays its role when trying to avoid the backend by carefully code splitting the app.

Frontend-only app split will allow for:

  • development speed as per less backend deps
  • flexibility when changes are requested

It also means less headache by not having to confront colleagues and management, and higher confidence as you remain in the javascript land - your comfortable area of expertise.

📩

If you face process or code challenges Google Search can't help you with, join my readers group. I send monthly updates with posts like this.


Resources

Top comments (3)

Collapse
 
link2twenty profile image
Andrew Bone

I like to have a custom hook like this in my projects. It in theory works back to IE10.

export default function useMatchMedia(query) {
  const [state, setState] = useState(null);

  useEffect(() => {
    const media = window.matchMedia(query);
    const func = (e) => {
      setState(e.matches);
    }

    try {
      media.addEventListener('change', func);
    } catch {
      media.addListener(func);
    }

    return () => {
      try {
        media.removeEventListener('change', func);
      } catch {
        media.removeListener(func);
      }
    }

  }, []);

  return state;
}
Enter fullscreen mode Exit fullscreen mode

It means you can do things like this to track media query changes.

const isMobile = useMatchMedia("(pointer: coarse) and (hover: none)");

useEffect(()=>{
  console.log(`somehow is mobile changed it is now ${isMobile}`)
}, [isMobile]);
Enter fullscreen mode Exit fullscreen mode
Collapse
 
moubi profile image
Miroslav Nikolov

Andrew that looks ok and will also work in the last project I did.

I had to deal with different approaches for getting the touch/desktop detection right:

  • server side detection
  • using the browser string in the frontend
  • using media queries

Things may become very annoying, that's why I decided to back my self up.

The detection logic is central for the app so you can extract it to the store. I am personally using Redux.

To supplement what has already been said in the article here is an implementation for those using some kind of store (redux in here):

Reducer + selector

// ui.js reducer
import { qualifySelector } from "../utils";
import { mobileQuery } from "../../lib/mediaQueries";

const name = "ui";
const initialState = {
  isTouch: mobileQuery.matches
};

function reducer(state = initialState, action) {
  switch (action.type) {
    case "SET_TOUCH_SUCCESS":
      return {
        isTouch: action.payload
      };
    default:
      return state;
  }
}

export default { [name]: reducer };

export const isMobileView = qualifySelector(name, state => state.isTouch);
Enter fullscreen mode Exit fullscreen mode

And the<Import /> component.

// Import.js
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import { isMobileView } from "../../../store/selectors";

export function Import({ touch, desktop, isMobile, children }) {
  const [Component, setComponent] = useState(null);

  useEffect(() => {
    const importCallback = isMobile ? touch : desktop;

    importCallback().then(componentDetails => {
      setComponent(componentDetails);
    });
  }, [desktop, touch, isMobile]);

  return children(Component ? Component.default : () => null);
}

Import.propTypes = {
  touch: PropTypes.func,
  desktop: PropTypes.func,
  children: PropTypes.func.isRequired,
  isMobile: PropTypes.bool.isRequired
};

export default connect(state => ({
  isMobile: isMobileView(state)
}))(Import);
Enter fullscreen mode Exit fullscreen mode

That way you will have detection logic agnostic approach and a central place to store the truth.

It may look like you are introducing a lot of unnecessary code, but it will pay off if your device detection changes dynamically during app lifecycle.


Fx. switching portrait to landscape mode may lead to serving desktop version instead of the touch one. Therefore you will need to update the store realtime with an action.

Collapse
 
spic profile image
Sascha Picard

Very informative article, thanks very much. I like that Import Component, makes it clear what is going on.