React Advanced

Topics

  • reducer hook and state management with reducers
  • immutability helper libraries
  • user authentication with an identity provider
  • progressive web apps
  • React Native
  • refs
  • wrapping existing elements
  • HOCs
  • internationalization

State management with actions and reducers

State management

In more complex applications or components it makes sense to manage the state (model) separately from the view.

Often the entire application state is represented by a data model and every change to the state will be done by triggering a change to the data model.

State management tools

  • reducer hook (included in React, conceptually similar to Redux)
  • Redux (based on reducers, commonly used with React)
  • recoil (based on React hooks, released by facebook in 2020)
  • MobX (commonly used with React)
  • ngrx (used with Angular)
  • vuex (used with vue)

State management tools

Redux devtools showing the state of the airbnb website
example: Redux devtools showing the complex state tree of the airbnb website

State management with actions

In the reducer hook, Redux, ngrx and vuex, each state change is triggered by an action, which is represented as a JavaScript object

note: vuex uses the term mutation

State management with actions

In Redux / reducer hook:

  • actions are represented by JavaScript objects
  • actions always have a type property
  • actions commonly also have a payload property

State management with actions

example actions:

{
  "type": "addTodo",
  "payload": "learn React"
}
{
  "type": "deleteTodo",
  "payload": 1
}
{
  "type": "deleteCompletedTodos"
}

State management with reducers

Technique that is used in Redux and in React's reducer hook:

  • a state transition happens through a reducer function
  • the reducer function receives the current state and an action
  • the reducer function returns the new state (without mutating the old state)

Reducer diagram

state action reducer

Example: todos state management

Manual usage of a reducer:

const state1 = [
  { id: 1, title: 'groceries', completed: false },
  { id: 2, title: 'taxes', completed: true },
];
const actionA = { type: 'addTodo', payload: 'gardening' };
const state2 = todosReducer(state1, actionA);
const actionB = { type: 'deleteTodo', payload: 1 };
const state3 = todosReducer(state2, actionB);
console.log(state3);
/* [{ id: 2, title: 'taxes', completed: true },
    { id: 3, title: 'gardening', completed: false },] */

Example: todos state management

reducer implementation:

const todosReducer = (oldState, action) => {
  switch (action.type) {
    case 'addTodo':
      return [
        ...oldState,
        {
          title: action.payload,
          completed: false,
          id: Math.max(0, ...oldState.map((t) => t.id)) + 1,
        },
      ];
    case 'deleteTodo':
      return oldState.filter(
        (todo) => todo.id !== action.payload
      );
    default:
      throw new Error('unknown action type');
  }
};

Example: todos state management

usage with TypeScript:

type TodosState = Array<Todo>;

type TodosAction =
  | { type: 'addTodo'; payload: string }
  | { type: 'deleteTodo'; payload: number };

const todosReducer = (
  state: TodosState,
  action: TodosAction
): TodosState => {
  // ...
};

Combining reducers

reducers can be easily combined / split to manage complex / nested state

state example:

{
  "todoData": {
    "status": "loading",
    "todos": []
  },
  "uiData": {
    "newTitle": "re",
    "filterText": ""
  }
}

Combining reducers

reducer implementation:

const rootReducer = (rootState, action) => ({
  todoData: todoDataReducer(rootState.todoData, action),
  uiData: uiDataReducer(rootState.uiData, action),
});

const uiDataReducer = (uiData, action) => ({
  newTitle: newTitleReducer(uiData.newTitle, action),
  filterText: filterTextReducer(uiData.filterText, action),
});

const newTitleReducer = (newTitle, action) => {
  if (action.type === 'setNewTitle') {
    return action.payload;
  } else if (action.type === 'addTodo') {
    return '';
  } else {
    return newTitle;
  }
};

Combining reducers

When combining reducers, a single reducer only manages part of the state; but every reducer receives any action and may react to it

Reducer Hook

Reducer Hook

For managing state we can now also utilize useReducer in addition to useState:

const [state, dispatch] = useReducer(reducer, initialState);

specific example:

const [count, countDispatch] = useReducer(countReducer, 0);

Reducer Hook

Calling useReducer returns an array with two entries:

  • current state
  • a dispatch function that can be used to trigger actions

Reducer Hook

const TodoApp = () => {
  const [todos, dispatch] = useReducer(
    todosReducer,
    initialTodos
  );

  return (
    <div>
      ...
      <button
        onClick={() => dispatch({ type: 'deleteAll' })}
      >
        delete all todos
      </button>
    </div>
  );
};

Reducer hook

The powerful Redux devtools may be used with the reducer hook: https://github.com/troch/reinspect (requires some configuration work, manually dispatching actions does not work)

Immutability helper libraries

Immutability helper libraries

working directly with immutable state can be complicated / tedious

helper libraries:

  • immer.js (commonly used with Redux)
  • immutable.js

immer.js

import produce from 'immer';

const todos = [
  // ...
];
const newTodos = produce(todos, (todosDraft) => {
  todosDraft[0].completed = true;
  todosDraft.push({ title: 'study', completed: false });
});

GraphQL and Apollo

GraphQL and Apollo

https://www.apollographql.com/docs/react/

GraphQL and Apollo

advantages over "plain" frontend code:

  • automatic sending of queries over the network
  • automatic caching
  • automatic (re)rendering of React components

Installation

required packages:

  • graphql
  • graphql-tag
  • apollo-client
  • apollo-cache-inmemory
  • apollo-link-http
  • react-apollo (for use with React)

Setup

import { ApolloClient } from 'apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import gql from 'graphql-tag';

const client = new ApolloClient({
  cache: new InMemoryCache(),
  link: new HttpLink({
    uri: 'https://api.spacex.land/graphql/',
  }),
});

Example query

// via a tagged template string
const LAUNCHES_QUERY = gql`
  query recentLaunches {
    launchesPast(limit: 10) {
      mission_name
    }
  }
`;

client
  .query({ query: LAUNCHES_QUERY })
  .then((result) => console.log(result));

Local data

Apollo can also manage local data / local state

Setting local state:

  • via client.writeData for simple cases
  • via the @client directive in GraphQL mutations; and local resolvers

Querying local state:

  • via the @client directive in GraphQL queries

Local data

Simple version: setting local state directly (similar to React's setState):

const client = useApolloClient();

client.writeData({ data: { inputText: '' } });

Local data

local resolvers for mutations:

https://www.apollographql.com/docs/react/data/local-state/#local-resolvers

Local data

Querying local state (via @client):

const INPUT_TEXT_QUERY = gql`
  query {
    inputText @client
  }
`;

client
  .query({ query: INPUT_TEXT_QUERY })
  .then((result) => console.log(result));

Apollo Client Developer Tools

extension for Chrome

unreliable according to reviews (3.2 / 5 stars)

functionality:

  • view the current cache
  • inspect the structure of queries / mutations
  • execute queries (and mutations)

React mit GraphQL und Apollo

React mit GraphQL und Apollo

https://www.apollographql.com/docs/react/data/queries/

Connecting React to an Apollo client

An application usually communicates with only one GraphQL API

import { ApolloProvider } from 'react-apollo';
<ApolloProvider client={client}>
  <App />
</ApolloProvider>

Defining a Query

const LAUNCHES_QUERY = gql`
  query recentLaunches {
    launchesPast(limit: 10) {
      mission_name
    }
  }
`;

useQuery

function RecentLaunches() {
  const { data, loading, error } = useQuery(LAUNCHES_QUERY);
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error!</div>;

  return (
    <div>
      <h1>Launches</h1>
      {data.launchesPast.map((launch) => (
        <div>{launch.mission_name}</div>
      ))}
    </div>
  );
}

useQuery: Parameters

const LAUNCHES_QUERY = gql`
  query recentLaunches($numLaunches: Int!) {
    launchesPast(limit: $numLaunches) {
      mission_name
    }
  }
`;

function RecentLaunches({ numLaunches }) {
  const { data, loading, error } = useQuery(
    LAUNCHES_QUERY,
    { variables: { numLaunches } }
  );
  ...
}

useQuery: polling & refetching

Updating every 5 seconds:

const { data, loading, error } = useQuery(LAUNCHES_QUERY, {
  pollInterval: 5000,
});

Updating via refetch():

const { data, loading, error, refetch } = useQuery(
  LAUNCHES_QUERY
);
...
refetch()

useMutation

Example for todos:

const SET_COMPLETED = gql`
  mutation setCompleted($id: ID!, $completed: Boolean!) {
    updateTodo(id: $id, input: { completed: $completed }) {
      id
      completed
    }
  }
`;

useMutation

basic usage:

const [setCompleted] = useMutation(SET_COMPLETED);

extended version (cf. useState):

const [
  setCompleted,
  { data, loading, error },
] = useMutation(SET_COMPLETED);

state is changed first on the server and then locally

useMutation

update of the local cache:

  • automatic if an existing object is updated
  • manual if entries are added to / removed from an array

useMutation: manual cache updates

accessing the cache and the reply inside the update function:

const [addTodo] = useMutation(ADD_TODO, {
  update: (cache, reply) => {
    // cache: local cache
    // reply: reply from the API
    console.log(cache);
    console.log(reply);
    // TODO: update the local cache based on the reply
  },
});

useMutation: manual cache updates

const [addTodo] = useMutation(ADD_TODO, {
  update: (cache, reply) => {
    // get old todos from cache
    const oldTodos = cache.readQuery({ query: GET_TODOS })
      .todos;
    // build newTodos array based on the server response
    const newTodos = [...oldTodos, reply.data.createTodo];
    // TODO: update the local cache with the newTodos array
  },
});

useMutation: manual cache updates

const [addTodo] = useMutation(ADD_TODO, {
  update: (cache, reply) => {
    const oldTodos = cache.readQuery({ query: GET_TODOS })
      .todos;
    const newTodos = [...oldTodos, reply.data.createTodo];
    cache.writeQuery({
      query: GET_TODOS,
      data: { todos: newTodos },
    });
  },
});

User authentication with an identity provider

Identity provider

an identity provider can verify the identity of users (can authenticate users)

examples:

the current user is logged in as user "foo" on this domain

the current user is authenticated as user "x" by Google / as user "y" by facebook

Identity provider

mechanism for the user:

user clicks on login, is redirected to a login page, and then sent back to the original site once logged in

in the background the user will receive an identity token, a piece of data that can prove their identity with the identity provider

Identity provider

standards:

  • authorization via OAuth2
  • authentication via OpenID Connect

Auth0

Auth0 (auth-zero) is a widely-used identity provider

supports authentication via "internal" accounts or external identity providers (e.g. Google, Apple, Facebook, ...)

Auth0: Registration and setup

  • register for an Auth0 account on https://auth0.com
  • in the sidebar, select "Applications"
  • select the default application or create a new "Single Page Web Application"; the selected name will be shown to users when they authenticate

Auth0: Registration and setup

Application Settings:

  • allowed redirect destinations after login: Allowed Callback URLs
  • allowed redirect destinations after logout: Allowed Logout URLs
  • for refreshing authentication tokens: Allowed Web Origins

for local development, set all three to http://localhost:3000

Domain and client ID

under Settings:

each Auth0 registrant has at least one domain (e.g. dev-xxxxxxxx.eu.auth0.com)

each app has a specific client ID (e.g. jA0EAwMCxamDRMfOGV5gyZPnyX1BBPOQ)

auth0-react

library: auth0-react

npm package: @auth0/auth0-react

include a provider that manages a context:

<Auth0Provider
  domain="YOUR_AUTH0_DOMAIN"
  clientId="YOUR_AUTH0_CLIENT_ID"
  redirectUri={window.location.origin}
>
  <App />
</Auth0Provider>

(see next slide for implementation in next.js)

auth0-react

when using next.js:

// pages/_app.js
export default function App({ Component, pageProps }) {
  return (
    <Auth0Provider
      domain="YOUR_AUTH0_DOMAIN"
      clientId="YOUR_AUTH0_CLIENT_ID"
      redirectUri="YOUR_URL"
    >
      <Component {...pageProps} />
    </Auth0Provider>
  );
}

auth0-react

const auth = useAuth0();

if (auth.isLoading) {
  return <div>...</div>;
} else if (auth.error) {
  return <div>...</div>;
} else if (!auth.isAuthenticated) {
  return (
    <button onClick={auth.loginWithRedirect}>Log in</button>
  );
} else {
  return (
    <div>
      main content
      <button onClick={auth.logout}>log out</button>
    </div>
  );
}

auth0-react

entries in the return value of useAuth0:

  • isLoading
  • error
  • isAuthenticated
  • loginWithRedirect
  • logout
  • user (user.sub, user.email, user.username, user.name, ...)

auth0-react

task: create a React app where the content is only accessible if the user is logged in and the username / email are displayed if available

auth0-react

authentication can be verified via access tokens (these are not JWT tokens)

more entries in the return value of useAuth0:

  • getAccessTokenSilently
  • getAccessTokenWithPopup

auth0-react

making a request with the access token:

async function makeRequestSilently() {
  const token = await auth.getAccessTokenSilently();
  console.log(`make API request with token ${token}`);
}

auth0-react

verifying an Auth0 token on the API side:

const auth0Domain = 'dev-xxxxxxxx.eu.auth0.com';

try {
  const res = await fetch(
    `https://${auth0Domain}/userinfo`,
    {
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${token}`,
      },
    }
  );
  const userInfo = await res.json();
  console.log(`authenticated as ${userInfo.sub}`);
} catch {
  console.log('error');
}

note: just sending user info (e.g. user ID) from the client is not secure since it can't be verified by the API; information can only be verified via a token

Resources

PWAs

Progressive Web Apps with React

PWAs

Progressive Web Apps enable us to write applications for PC and mobile using HTML, CSS and JavaScript

PWAs

create-react-app can be used to initialize React projects with basic PWA support

npx create-react-app myapp --template cra-template-pwa
npx create-react-app myapp --template cra-template-pwa-typescript

Codesandbox has built-in support for very basic PWAs

PWAs

PWA basics in create-react-app projects:

  • configuration in public/manifest.json
  • PWA-Boilerplate in src/serviceWorker.js

PWAs: activation

in index.js / index.tsx:

serviceWorker.register();

PWAs: configuration

Via public/manifest.json

PWA: add to homescreen

Procedure in Chrome:

  • wait until Chrome will allow the install prompt to be displayed
  • display a button or the like that offers installation
  • when the button is clicked, make Chrome display an installation prompt

see also: https://developers.google.com/web/fundamentals/app-install-banners/

PWA: add to homescreen

TypeScript implementation:

const [canInstall, setCanInstall] = useState(false);
const installPromptEventRef = useRef<Event>();

const getInstallPermission = () => {
  window.addEventListener(
    'beforeinstallprompt',
    (ipEvent) => {
      ipEvent.preventDefault();
      installPromptEventRef.current = ipEvent;
      setCanInstall(true);
    }
  );
};
useEffect(getInstallPermission, []);

PWA: add to homescreen

TypeScript implementation:

<button
  disabled={!canInstall}
  onClick={() => {
    (installPromptEventRef.current as any).prompt();
  }}
>
  install
</button>

PWA: Deployment

React Native

React Native

React Native can be used to write React applications for iOS and Android devices

Options for development

  • Expo: simple option, quick to get started
  • React Native CLI: enables integration of native modules (Java / Objective-C)

Expo tools

  • Expo Snack: online editor / playground
  • Expo CLI: local development
  • Expo App: emulator for live testing on Android / iOS (available on app stores)

Expo Snack

https://snack.expo.io

options:

  • run web version
  • emulate Andoid / iOS online (limited capacity)
  • run on local device (via Expo App)

Expo CLI

installation:

npm install -g expo-cli

creating a new expo project:

expo init myproject

running a project (will open a dashboard at localhost:19002):

npm run start

Expo CLI

running on a device:

  • select tunnel
  • wait
  • scan QR code with Expo app

React Native components

  • View (=div)
  • Text
  • Image
  • Button
  • TextInput
  • ScrollView

detailed list

React Native components

Examples:

<Button title="press me" onPress={handlePress} />
<TextInput value={myText} onChangeText={setMyText} />

Styling

All React Native components accept a style prop that can take an object:

const TodoItem = ({ title, completed }) => (
  <View style={{ margin: 5, padding: 8 }}>
    <Text>{title}</Text>
  </View>
);

Styling

The style prop can also receive an array of objects which are merged (falsy entries are ignored)

const TodoItem = ({ title, completed }) => (
  <View
    style={[
      { padding: 8, backgroundColor: 'lightcoral' },
      completed && { backgroundColor: 'lightgrey' },
    ]}
  >
    <Text>{title}</Text>
  </View>
);

Styling

Creating stylesheets that define multiple sets of styles:

import { StyleSheet } from 'react-native';

const styles = StyleSheet.create({
  todoItem: {
    padding: 8,
    backgroundColor: 'lightcoral',
  },
  completedView: {
    backgroundColor: 'lightgrey',
  },
  completedText: {
    textDecoration: 'line-through',
  },
});

Styling

using stylesheets:

const TodoItem = ({ title, completed, onToggle }) => (
  <View
    style={[
      styles.todoItem,
      completed && styles.completedView,
    ]}
  >
    <Text style={[completed && styles.completedText]}>
      {completed ? 'DONE: ' : 'TODO: '}
      {title}
    </Text>
  </View>
);

Platform-specific code

option 1 (simple cases):

import { Platform } from 'react-native';
if (Platform.OS === 'web') {
  // 'web' / 'ios' / 'android'
}

option 2 (platform-specific components):

  • AddTodo.web.js
  • AddTodo.ios.js
  • AddTodo.android.js

Wrapping existing elements

Wrapping existing elements

Example: a Button component which renders a button element with additional styling

The Button component should have the same properties as a button element

Wrapping existing elements

<Button type="submit" disabled={true}>
  foo
</Button>

should render:

<button type="submit" disabled={true} className="Button">
  foo
</button>

Wrapping existing elements

implementation:

function Button(props: ComponentProps<'button'>) {
  // return a "button" element with one extra CSS class
  return <button {...props} className="Button" />;
}

Extra properties

example: component with an extra property

type Props = ComponentProps<'input'> & {
  label: string;
};

function InputWithLabel({ label, ...rest }: Props) {
  return (
    <label>
      {label}: <input {...rest} />
    </label>
  );
}

Forwarding the ref property

Extra wish: The ref property of the Button component should also point to the button element

Forwarding the ref property

const Button = forwardRef<
  HTMLButtonElement,
  ComponentProps<'button'>
>((props, ref) => {
  return <button {...props} ref={ref} className="Button" />;
});

Higher-order components

functions that modify component definitions

Higher-order components

confusing terminology:

Higher-order components are not components 😲

Higher-order components are functions that modify / enhance a component definition (they are "component decorators")

Higher-order components

Example:

React's memo is a higher-order component

It receives a component and returns a memoized component:

const MemoizedRating = memo(Rating);

Higher-order components

Example:

connect from react-redux returns a HOC:

// connector is a HOC
const connector = connect(
  mapStateToProps,
  mapDispatchToProps
);

The resulting HOC receives a regular component and returns a component that is connected to the Redux store:

const RatingContainer = connector(Rating);

Render props

Render props

render props: pattern that allows a parent to provide additional or derived data when rendering a child

the parent receives instructions for how to render data - these instructions are provided as functions

Render props

Wishful thinking - if only this would work:

<DataLoader resource="https://jsonplaceholder.typicode.com/todos">
  <DataViewer />
</DataLoader>

one way to make it work:

<DataLoader
  resource="https://jsonplaceholder.typicode.com/todos"
  render={(data) => <DataViewer data={data} />}
/>

Render props

full example: DataLoader

<DataLoader
  resource="https://jsonplaceholder.typicode.com/todos"
  renderLoading={() => <div>loading</div>}
  renderError={(error) => <div>Error: ...</div>}
  render={(data) => <DataViewer data={data} />}
/>

Render props

example: DataTable

<DataTable
  data={todos}
  filter={(todo) => !todo.completed}
  renderRow={(todo) => <tr>{todo.title}</tr>}
/>

Render props

example: formik library

<Formik
  initialValues={/*...*/}
  onSubmit={/*...*/}
  validate={/*...*/}
  children={(props) => <Form>...</Form>}
/>

Internationalization

Internationalization

libraries:

  • react-i18next (based on i18next)
  • react-intl

i18next - basics

// src/i18n.ts

import i18next from 'i18next';
import { initReactI18next } from 'react-i18next';

i18next - basics

setup with inline data:

// src/i18n.ts

i18next.use(initReactI18next).init({
  debug: true,
  lng: 'en',
  resources: {
    en: {
      translation: { hello: 'Hello!', sign_in: 'Sign in' },
    },
    de: {
      translation: { hello: 'Hallo!', sign_in: 'Anmelden' },
    },
  },
});

i18next - basics

including in the project in src/index.tsx:

// ...
import './i18n';
// ...

testing its functionality:

import i18next from 'i18next';

console.log(i18next.t('hello'));
console.log(i18next.t('sign_in', { lng: 'de' }));

i18next - basics

accessing the translation function via a hook:

function SignInButton() {
  const { t } = useTranslation();

  return <button>{t('sign_in')}</button>;
}

i18next - topics in depth

  • translation files hosted on the server
    • loading via suspense
  • namespaces
  • interpolation
  • plural
  • changing languages

resources:

Portals

Portals

Portals: allow rendering of HTML elements "outside" the component they are created in

Portals

example: Dialog component

A Dialog can be rendered from any component - its rendering will become a child of the HTML body element

function Dialog(props: { children: React.ReactNode }) {
  return ReactDOM.createPortal(
    <div style={dialogStyle}>{props.children}</div>,
    document.querySelector('body') as HTMLBodyElement
  );
}

Error boundaries

Error boundaries

goal:

catch errors in a deployed application and show "nice" error messages to users

Error boundaries

example: catch runtime errors in the entire application

<MyErrorBoundary>
  <App />
</MyErrorBoundary>

Error boundaries

Error boundary components can only be implemented as class components

Error boundaries will catch these errors of subcomponents:

  • errors in the rendering code / JSX
  • errors inside lifecycle methods / effect hooks

Error boundaries

implementation of an ErrorBoundary component:

type Props = { children: React.ReactNode };
type State = { hasError: boolean };
class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }
  static getDerivedStateFromError(error: Error): State {
    return { hasError: true };
  }
  render() {
    if (this.state.hasError) {
      return <div>Something went wrong ...</div>;
    }
    return this.props.children;
  }
}