DEV Community

Cover image for A Material DEV Client on Six Platforms with 100% code sharing
Cody Pearce
Cody Pearce

Posted on • Updated on • Originally published at codinhood.com

A Material DEV Client on Six Platforms with 100% code sharing

Dev.To.Material is a simple Material 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, Material Bread, and more.

Unfortunately, much of the 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

Github

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.

We're going to use react-native-infinity 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
Enter fullscreen mode Exit fullscreen mode

Alt Text

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 and react-navigation 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
Enter fullscreen mode Exit fullscreen mode

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, 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";
Enter fullscreen mode Exit fullscreen mode

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";
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Adding fonts to iOS and Android is a little more complicated, follow this excellent article 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 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

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%'
  }
});
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 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 to provide media queries for browsers and use dimensions for native.

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

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
};
Enter fullscreen mode Exit fullscreen mode

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>,
...
Enter fullscreen mode Exit fullscreen mode

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

// src/Components/Layout.js
...
<TabletOrMobile>
    <Fab containerStyle={styles.fab} />
</TabletOrMobile>
...
Enter fullscreen mode Exit fullscreen mode

Alt Text

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);
Enter fullscreen mode Exit fullscreen mode

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

...
<Drawer
      open={isOpen}
      type={isMobile ? "modal" : "permanent"}
...
Enter fullscreen mode Exit fullscreen mode

Alt Text

The rest of the UI was built using similar patterns. If you're interested, check out the github repo to see the rest of the components.

Dev.to API

The 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>
  );
...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
};
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

Alt Text

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
};
Enter fullscreen mode Exit fullscreen mode

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.

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)\/).*/,
Enter fullscreen mode Exit fullscreen mode

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>
...
Enter fullscreen mode Exit fullscreen mode

Alt Text

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>
...

Enter fullscreen mode Exit fullscreen mode

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

Conclusion

Alt Text

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", and trying to get Xcode and iOS 13 to work correctly, Unknown argument type ‘attribute’ in method. Furthermore, while building Material Bread, I found z-index barely works on Android. 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 to learn more.

Top comments (7)

Collapse
 
andrewbrown profile image
Andrew Brown 🇨🇦 • Edited

Already started fiddling. I think my gripes would need more styling examples.
Been trying to figure out on the add a border right to the Navigation Panel for a good 10 minutes.

Having had the displeasure to build both a native iOS and Andriod app in parallel its nice to see such normalization accomplished across multiple platforms.

Collapse
 
andrewbrown profile image
Andrew Brown 🇨🇦
Collapse
 
codypearce profile image
Cody Pearce • Edited

Thanks! You can a border right to the <Drawer /> by adding styles to the drawerStyle prop:

drawerStyle={{borderRightWidth: 10, borderRightColor: 'black'}}

Collapse
 
ben profile image
Ben Halpern

Wow this is truly epic

Collapse
 
codypearce profile image
Cody Pearce

Thanks! I spent about a 1-2 weeks working it so it doesn't have all the functionality or components I originally wanted, but I think it does show how much code you can share across platforms these days.

Collapse
 
mkenzo_8 profile image
mkenzo_8

It looks veeery good! Good job :) Just gave an star on the GitHub repo.

🤩👍

Collapse
 
kris profile image
0xAirdropfarmer

Good job just star and fork