A Material DEV Client on Six Platforms with 100% code sharing
Tags: reactnative, react, showdev, tutorial
Published: 2019-12-10 16:09:00


Dev.To.Material is a simple Material [Dev.to](https://dev.to/) client built with React Native that runs on the Web, Android, iOS, and Electron (Windows, Linux, and MacOS). All code is shared between each platform using React Router, [React Native Web](https://github.com/necolas/react-native-web), [Material Bread](https://material-bread.org/), and more.

Unfortunately, much of the [Dev.to API](https://docs.dev.to/api/) api is undocumented and authentication with `oauth2` is in a private alpha. Therefore, although much of the UI for the Home page and Article page has been created, much of the functionality has not been implemented yet. Currently, however, you can click on articles, sort articles by topic button (feed, week, etc), search articles by tags, and click on tags to sort.


![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/a0afeu28mo70jp0roitx.gif)

[Github](https://github.com/codypearce/devto-material)

This article provides a small guide on how to build a React Native app across six platforms while sharing 100% of the code.

- Setting up a Mono Repo
- Cross-Platform Router
- Cross-Platform Utilities and Fonts
- Cross-Platform UI Components and Layout
- Cross-Platform Styles and Responsiveness
- Dev.to API
- Rendering Post HTML Cross-Platform
- Conclusion

## Setting up a Cross-Platform MonoRepo

Sharing code within a monorepo is significantly easier than sharing code across multiple repos. Additionally, sharing code within a React Native mono repo is surprisingly simple to setup. Essentially, each platform has its own folder that contains the configuration necessary to render the app on that platform. You can learn more about this in my previous article, [Creating a Dynamic Starter Kit for React Native](https://www.consolelogs.dev/dynamic-project-starters/).

We're going to use [react-native-infinity](https://www.reactnativeinfinity.com) to generate the minimum configuration required to share code across all platforms. Simply initialize a new project with the name, platforms, and UI library you want to use.

```
npx react-native-infinity init
```

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/2u1qnp3sw2e5bvkwybm5.gif)

Follow the instructions printed in the terminal to complete the setup.

We now have a cross-platform monorepo that renders the `src` folder on all platforms. While developing, it's important to constantly test changes on multiple platforms and screen sizes. Often a seemingly insignificant change on one platform can completely break on a different platform.

## Cross-Platform Router

Both [react-router](https://reacttraining.com/react-router/) and [react-navigation](https://reactnavigation.org/en/) support web and native routers. However, I kept running into problems with React Navigation, and overall found it much more difficult to use and customize. React Router, on the other hand, was extremely easy to setup and I never ran into any problems. To set up React Router across platforms, we need to install three packages, `react-router`, `react-router-dom`, `react-router-native`.

```
npm install react-router react-router-dom react-router-native
```

`react-router-dom` and `react-router-native` provide the same components (Router, Route, Link, etc) for the web and native (iOS and Android) respectively. All we need to do is import the correct components for each platform. This is easily done using [Native-specific extensions](https://facebook.github.io/react-native/docs/platform-specific-code#native-specific-extensions-ie-sharing-code-with-nodejs-and-web), which selects files for particular platforms based off the file extension.

Create a new file `src/Router/index.js` that exports the `react-router-native` components.

```
export {
  NativeRouter as Router,
  Route,
  Switch,
  Link
} from "react-router-native";
```

In the same folder, create the file `src/Router/index.web.js` that exports the `react-router-dom` components.

```
export { BrowserRouter as Router, Route, Switch, Link } from "react-router-dom";
```

Whenever we need to use the router we can import the components from our local folder `Router`, and the bundler should pick up the correct file.

Next, create the `src/Routes.js` file to house all the pages in the app. As mentioned above, import the router components from our local folder, Router, rather than the `react-router-*` packages.

```
// src/Routes.js

import React from "react";
import { View } from "react-native";

import Home from "./Screens/Home";
import Post from "./Screens/Post";

import { Route, Router, Switch } from "./Router";

function Routes() {
  return (
    <Router>
      <View style={{ backgroundColor: "#f2f6ff", minHeight: "100%" }}>
        <Switch>
          <Route exact path="/" component={Home} />
          <Route exact path="/post/:id" component={Post} />
        </Switch>
      </View>
    </Router>
  );
}

export default Routes;
```

Now, create two very simple screens with `Link` components to navigate back and forth.

```
// src/Screens/Home.js

import React from "react";
import { View, Text } from "react-native";
import { Link } from "../Router";

export default function Home() {
  return (
    <View>
      <Link to="/post/1">
        <Text>To Post</Text>
      </Link>
    </View>
  );
}
```

```
// src/Screens/Post.js

import React from "react";
import { View, Text } from "react-native";
import { Link } from "../Router";

export default function Home() {
  return (
    <View>
      <Link to="/post/1">
        <Text>To Post</Text>
      </Link>
    </View>
  );
}
```

Finally, update `src/App.js` to use the new Routes we setup.

```
import React from "react";
import { View } from "react-native";
import Routes from "./Routes";

export default class App extends React.Component {
  render() {
    return (
      <View>
        <Routes />
      </View>
    );
  }
}
```

You should now be able to navigate between the `Home` screen and `Post` screen on each platform.

## Cross-Platform Utilities and Fonts

Devices and platforms often have subtle differences that require special rules, for example, the Iphone X's notch. Therefore, we will need to apply styles and other logic per platform. Create `src/Styles/device.js`, as follows:

```
import { Platform } from "react-native";

const isWeb = Platform.OS == "web";
const isAndroid = Platform.OS == "android";
const isIos = Platform.OS == "ios";

export { isWeb, isAndroid, isIos };
```

We will often need to reference a device's screen width and height when building the layout. Create `src/Styles/dimensions.js` to export the dimensions

```
import { Dimensions, Platform } from "react-native";

const screenHeight = Dimensions.get("window").height;
const screenWidth = Dimensions.get("window").width;
const trueHundredHeight = Platform.OS == "web" ? "100vh" : "100%";

export { screenHeight, screenWidth, trueHundredHeight };
```

Next, create `src/Styles/theme.js` to hold the apps theme.

```
import { isWeb } from "./device";

const theme = {
  background: "#f7f9fc",
  bodyBackground: "#f2f6ff",
  fontFamily: isWeb ? "Roboto, sans-serif" : "Roboto"
};

export default theme;
```

Finally, we need to provide the Roboto font for all platforms. Adding Roboto to the Web and Electron is quite simple, just add an import from Google Fonts in both `web/index.html` and `electron/index.html`, between two `<style>` tags.

```
@import url("https://fonts.googleapis.com/css?family=Roboto&display=swap");
```

Adding fonts to iOS and Android is a little more complicated, follow [this excellent article](https://medium.com/react-native-training/react-native-custom-fonts-ccc9aacf9e5e) to learn how.

## Cross-Platform UI Components and Layout

Creating a user interface across screens sizes, platforms, and devices is extremely time consuming. The more components we can share across platforms, the better. With that in mind, we're going to [Material Bread](https://material-bread.org/) which provides Material Design components that work across all platforms. If you added Material Bread with React Native Infinity, then everything is setup already, if not then please visit the docs to get started.

The essential layout is composed of an `Appbar`, `Drawer`, and the actual page `Content`. These can be shared across platforms, but they need to act differently depending on the screen width and screen size.

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/p9jdypamy7qbp8986srr.png)

We can create this structure easily with the `Drawer` component. Page content is rendered as a child of the `Drawer` component and the Appbar is rendered by the `appbar` prop.

```
// src/Screens/Home.js

import React, { useState } from "react";
import { View, Text, Platform, StyleSheet } from "react-native";
import { Drawer } from "material-bread";
import { trueHundredHeight } from "../Styles/dimensions";
import theme from "../Styles/theme";

export default function Home() {
  const [isOpen, setisOpen] = useState(true);

  return (
    <Drawer
      open={isWeb ? true : isOpen}
      type={"permanent"}
      onClose={() => setisOpen(false)}
      drawerContent={
        <View>
          <Text>Drawer Content</Text>
        </View>
      }
      style={styles.pageContainer}
      drawerStyle={styles.drawer}
      appbar={<View style={styles.appbar} />}
    >
      <View style={styles.body}>
        <View style={{ flexDirection: "row" }}></View>
      </View>
    </Drawer>
  );
}

const styles = StyleSheet.create({
  pageContainer: {
    height: "auto",
    minHeight: trueHundredHeight,
    backgroundColor: theme.background
  },
  drawer: {
    borderRightWidth: 0,
    height: "100%"
  },
  body: {
    width: "100%",
    paddingTop: 34,
    backgroundColor: theme.bodyBackground,
    padding: 34,
    minHeight: trueHundredHeight
  },
  appbar: {
    height: 56,
    width: '100%'
  }
});
```

Although this layout will work across platforms, it won't look good across screen sizes. For example, the drawer will stay open on very small screen sizes and hide all of the content. Therefore, the next problem we need to tackle is responsive styles.

## Cross-Platform Styles and Responsiveness

An initial approach at cross-platform responsiveness is use the `Dimensions` property to create breakpoints.

```
const isMobile = Dimensions.get("window").width < 767;
```

The obvious problem is that the values won't update when the width of the window changes. Another approach, is to use React Native's `onLayout` prop to listen for layout changes on a particular component. A library like [react-native-on-layout](https://github.com/shichongrui/react-native-on-layout) can make this easier, but it's not ideal in my opinion. Other packages for adding responsiveness to React Native are not well supported on the web.

Instead, we can create a hybrid approach by using [react-responsive](https://www.npmjs.com/package/react-responsive) to provide media queries for browsers and use `dimensions` for native.

```
const isMobile =
    Platform.OS == "web" ? useMediaQuery({ maxWidth: 767 }) : screenWidth < 767;
```

This will update when the browser width is resized and respond to the breakpoint for mobile devices. We can expand this and create some useful responsive components to use across the app.

```
import { useMediaQuery } from "react-responsive";
import { isWeb } from "./device";
import { screenWidth } from "./dimensions";

// Breakpoints
const desktopBreakpoint = 1223;
const tabletBreakpoint = 1023;
const mobileBreakpoint = 767;

// Native Resposive
const isDesktopNative = screenWidth > desktopBreakpoint;
const isLaptopOrDesktopNative = screenWidth > tabletBreakpoint + 1;
const isLaptopNative =
  screenWidth > tabletBreakpoint + 1 && screenWidth < desktopBreakpoint;
const isTabletNative =
  screenWidth < tabletBreakpoint && screenWidth > mobileBreakpoint + 1;
const isTabletOrMobileNative = screenWidth < tabletBreakpoint;
const isMobileNative = screenWidth < mobileBreakpoint;

// Cross-Platform Responsive Components
const Desktop = ({ children }) => {
  const isDesktop = isWeb
    ? useMediaQuery({ minWidth: desktopBreakpoint })
    : isDesktopNative;
  return isDesktop ? children : null;
};

const LaptopOrDesktop = ({ children }) => {
  const isDesktop = isWeb
    ? useMediaQuery({ minWidth: tabletBreakpoint + 1 })
    : isLaptopOrDesktopNative;
  return isDesktop ? children : null;
};

const Laptop = ({ children }) => {
  const isDesktop = isWeb
    ? useMediaQuery({
        minWidth: tabletBreakpoint + 1,
        maxWidth: desktopBreakpoint
      })
    : isLaptopNative;
  return isDesktop ? children : null;
};

const Tablet = ({ children }) => {
  const isTablet = isWeb
    ? useMediaQuery({
        minWidth: mobileBreakpoint + 1,
        maxWidth: tabletBreakpoint
      })
    : isTabletNative;
  return isTablet ? children : null;
};
const TabletOrMobile = ({ children }) => {
  const isTablet = isWeb
    ? useMediaQuery({
        maxWidth: tabletBreakpoint
      })
    : isTabletOrMobileNative;
  return isTablet ? children : null;
};
const Mobile = ({ children }) => {
  const isMobile = isWeb
    ? useMediaQuery({ maxWidth: mobileBreakpoint })
    : isMobileNative;
  return isMobile ? children : null;
};

export {
  mobileBreakpoint,
  tabletBreakpoint,
  desktopBreakpoint,
  isDesktopNative,
  isLaptopOrDesktopNative,
  isLaptopNative,
  isTabletNative,
  isTabletOrMobileNative,
  isMobileNative,
  Desktop,
  LaptopOrDesktop,
  Laptop,
  Tablet,
  TabletOrMobile,
  Mobile
};
```

For example, we can use this to only show the `Appbar` button "Write a post" on laptop screen sizes and above:

```
// src/Components/Appbar/Appbar.js
...
actionItems={[
        <LaptopOrDesktop key={1}>
          <Button
            text={"Write a post"}
            onPress={this.createPost}
            type="outlined"
            icon={<Icon name={"send"} />}
            radius={20}
            borderSize={2}
            style={{ marginRight: 8 }}
          />
        </LaptopOrDesktop>,
...
```

And then show the `Fab` button on tablet and mobile screen sizes.

```
// src/Components/Layout.js
...
<TabletOrMobile>
	<Fab containerStyle={styles.fab} />
</TabletOrMobile>
...
```

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/znykrxxxo9dsyo88i9mr.gif)

Applying the same logic to the `Drawer`, we can hide the `Drawer` on mobile. `useMediaQuery`'s third argument takes a callback function and sends along whether the media query matches. We can use this to call `setIsOpen` to false when the window width is below the `mobileBreakpoint`.

```
const handleIsMobile = matches => setisOpen(!matches);

const isMobile = useMediaQuery({ maxWidth: mobileBreakpoint }, undefined, handleIsMobile);

const [isOpen, setisOpen] = useState(isMobile ? false : true);
```

Lastly, we can set the `Drawer` type to `modal`, to match what we would expect on mobile.

```
...
<Drawer
      open={isOpen}
      type={isMobile ? "modal" : "permanent"}
...
```

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/06yj485tnfi3ndfc8fxe.gif)

The rest of the UI was built using similar patterns. If you're interested, check out the [github repo](https://github.com/codypearce/devto-material) to see the rest of the components.

## Dev.to API

The [Dev.to API](https://docs.dev.to/api/) is still in beta and much of the functionality has not been documented yet. Therefore, for this app we will only be concerned with fetching posts. If more of the API were open, I might use a more robust state management system, but for now I'll simply create some hooks.

Let's write a simple async function to fetch posts with error handling.

```
// src/Screens/Home.js
...
const [posts, setPosts] = useState(initialState.posts);
const [isLoading, setIsLoading] = useState(initialState.isLoading);
const [hasError, setHasError] = useState(initialState.hasError);

const fetchPosts = async () => {
    setIsLoading(true);

    try {

      const result = await fetch(`https://dev.to/api/articles`);
      const data = await result.json();
      setPosts(data);
      setHasError(false);
    } catch (e) {
      setIsLoading(false);
      setHasError(true);
    }
};
useEffect(() => {
    fetchPosts();
}, []);

 return (
    <Layout>
      <PostList posts={posts} hasError={hasError} isLoading={isLoading} />
    </Layout>
  );
...
```

Check out the Github Repo to see the `PostList` component.

The buttons on top of the main card list ("Feed", "Week", etc) are simple filters on the request above. Week, for example, can be fetched by appending `top=7` to the original request.

```
https://dev.to/api/articles/?top=7
```

We can create a simple function to append these queries onto the root `url` using the `history` object from React Router.

```
function HandleNavigate({filter, type, history}) {
    const link = type ? `?${type}=${filter}` : "/";

    history.push(link);
}
```

Then, back on the Home screen, we can use React Router's `location` object to append those queries to the fetch.

```
const fetchPosts = async () => {
    setIsLoading(true);

    try {
      const queries = location.search ? location.search : "/";

      const result = await fetch(`https://dev.to/api/articles${queries}`);
      const data = await result.json();
      setPosts(data);
      setHasError(false);
      setTimeout(() => {
        setIsLoading(false);
      }, 600);
    } catch (e) {
      setIsLoading(false);
      setHasError(true);
    }
};
```

Lastly, we need to add the `location` object to second argument of `useEffect` so that it will fire `fetchPosts` when the `location` object has updated.

```
useEffect(() => {
    fetchPosts();
}, [location]);
```

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/ozf030eebydp0167hred.png)

Tags (#javascript, #react, etc) work the exactly the same way. Simply pass the tag name into the query param `tag`. For example, this will fetch posts with the tag javascript.

```
https://dev.to/api/articles/?tag=javascript
```

Although we cannot implement a real search with the API currently (ss far as I know) we can implement a simple tag search by following the same pattern and passing the input to the tag query param.

```
const [search, setSearch] = useState(initialState.search);

function HandleNavigate(search) {
    if (!search) return;
    const link = search ? `?tag=${search}` : "/";

    history.push(link);
}
```

## Rendering Post HTML Cross-Platform

The process for fetching a specific post is similar to fetching a list of posts. Simply pass the `postId` to the `/articles` endpoint.

```
const fetchPost = async () => {
    setIsLoading(true);
    const postId = match && match.params && match.params.id;

    try {
      const result = await fetch(`https://dev.to/api/articles/${postId}`);
      const data = await result.json();

      setPost(data);
      setHasError(false);
      setIsLoading(false);
    } catch (e) {
      setIsLoading(false);
      setHasError(true);
    }
};
```

Displaying the post, however, is more tricky. The Dev.to API provides each post in two formats, html (`body_html`) and markdown (`body_markdown`). Although packages exist to render markdown on each platform, I found it difficult to get each post to render correctly on all platforms. Instead we can accomplish this by using the post `html`.

For web apps, we could use `dangerouslySetInnerHtml` to render a full post, but obviously this won't work on React Native. Instead we can use an excellent package, [react-native-render-html](https://github.com/archriss/react-native-render-html).

First, we need to transform `react-native-render-html` with Webpack, replace the exclude line in both `web/webpack.config.js` and `electron/webpack.config.js` with the following:

```
test: /\.(js|jsx)$/,
exclude: /node_modules\/(?!(material-bread|react-native-vector-icons|react-native-render-html)\/).*/,
```

Then, pass the `post.body_html` to the `HTML` component from `react-native-render-html`.

```
// src/Screens/Post.js

...
import HTML from "react-native-render-html";
...
<Layout>
    <Card style={styles.postCard}>
    {post && post.cover_image ? (
        <Image
        source={{ uri: post && post.cover_image }}
        style={[ styles.postImage ]}
        />
    ) : null}

    <Heading type={3} text={post && post.title} />
    <Heading type={5} text={post && post.user && post.user.name} />
    {post && !isLoading ? (
        <HTML html={post.body_html} />
    ) : (
        <Loader isLoading={isLoading} />
    )}
    {hasError ? <Text>Something went wrong fetching the post, please try again</Text> : null}
    </Card>
</Layout>
...
```

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/ivic17731wcj9ak45kyy.png)

This works great across platforms, however, the post images are extending past the cards. `react-native-render-html` provides a prop `imagesMaxWidth` to set the image's max width, but it is not responsive. Unlike other responsive issues, we want the image's width to be determined by the containing `Card`, not the window width. So instead of using the responsive components we defined above, we need to fall back to use the `onLayout` prop described previously.

Add the `onLayout` prop `<View>` component with a callback function that sets the `cardWidth` equal to `Card`. Then set the `imagesMaxWidth` prop on the `HTML` component to the `cardWidth`.

```
const [cardWidth, setCardWidth] = useState(initialState.cardWidth);
const onCardLayout = e => {
    setCardWidth(e.nativeEvent.layout.width);
};
...
<Card style={styles.postCard}>
    <View onLayout={onCardLayout}>
	...
	{post && !isLoading ? (
        <HTML html={post.body_html} imagesMaxWidth={cardWidth} />
        ) : (
        <Loader isLoading={isLoading} />
    )}
    </View>
</Card>
...

```

Now the post's image will update its width whenever the `PostCard` width is updated.

### Conclusion

![Alt Text](https://thepracticaldev.s3.amazonaws.com/i/rbg2vz2rrilm54uyjvzm.png)

React Native, along with many other tools, allows us to write one app and render it on many platforms. Although there are certainty aspects that need improvement, like responsiveness and animations, the fact that a small team can reasonably build apps for multiple platforms without expertise in multiple languages and platforms really opens the playing field for solo developers or smaller companies.

Having said that, React Native development can also be quite frustrating. For example, I wasted a few hours in Github issues and Stackoverflow trying to get the bundle to load on iOS, [react-native bundle` hangs during "Loading"](https://medium.com/@TETRA2000/react-native-bundle-hangs-during-loading-dependency-graph-f40bc7347125), and trying to get Xcode and iOS 13 to work correctly, [Unknown argument type ‘attribute’ in method](https://medium.com/@igniz87/react-native-unknown-argument-type-attribute-in-method-rctappstate-5daf904b2367). Furthermore, while building Material Bread, I found `z-index` [barely works on Android](https://github.com/facebook/react-native/issues/23836#issuecomment-529190312). These aren't necessarily deal breakers, but spending all this time on problems like these can really stall development.

Despite these issues, all the code in this project is 100% shared on all platforms, only a few components required any logic specific to a platform. Obviously, I didn't cover every part of the app, but feel free to ask or check out the [Github Repo](https://github.com/codypearce/devto-material) to learn more.