Help us understand the problem. What is going on with this article?

ReactでUserやテーマなどのグローバル値をRedux抜きのContextAPIだけで乗り切る

More than 1 year has passed since last update.

Goal

ReactでUserやテーマなどのグローバル値を、Reduxを使わずContextAPIだけで乗り切る。

対象ユーザー

  • Reactの基本を理解している。
  • Firebae Authを使ってReactでユーザーのログイン状態を連携させることを理解している。
  • ContextAPIの存在を何となく知っている。

いざ実践

TODO

  1. 一つのコンポーネントで複数のContextを扱いたい。
  2. 子コンポーネントからContextをUpdateしたい。
  3. 各コンポーネントでConsumerタグを冗長に書きたくない。

1と2は公式DocumentのContextのページでしっかり回答されてまして、唯一頑張ったのが3ですね(;^_^A
https://reactjs.org/docs/context.html
3についてはHigher-Order Componentsという仕組みがあるので、それを利用しました。
https://reactjs.org/docs/higher-order-components.html
HOCは自分で作ったことはなかったんですが、has-aのような関係性を構築できる有用性をようやく理解できた気がする(;'∀')
余談ですが、AWSのAWS Amplifyのセッションに参加した際、HOCによりお客さんは認証画面の表示管理をライブラリに任せられるので、HOC便利とおっしゃってました。

構成解説

今回の例ではタイトルバー+ユーザーメニュー(menuappbar.js)とスナックバー(snackbar.js)で画面を構成します。
それらの親コンポーネントとしてlayout.jsを配備。
Contextにもつデータは、Userのログイン関連データ(usercontext.js)、スナックバーを含んだデータ(systemcontext.js)としています。
親コンポーネントのstateで管理したいので、データについてもlayout.jsで管理します。
あと、Consumerから値を取り出して任意のコンポーネントにそれらのpropsに渡すため、HOCとしてwithSystemContext.jsがいます。

ユーザーメニューのログアウトボタン押下で、親コンポーネントにあるスナックバーのメッセージを更新するのが今回のミッションです。

ログイン.PNG
ログアウト.PNG

ソース

ちなみにupdateSnackbarMessage()で最初スプレッド演算子(...)がなかったので、当初state.systemからupdateSnackbarMessageが消えるバグが出たのは内緒です( ´Д`)=3 フゥ

src/pages/layout.js
import React from 'react';
import { withStyles } from '@material-ui/core/styles';
import withRoot from '../withRoot';

import { UserContext } from '../contexts/user'
import { SystemContext } from '../contexts/system'

import StyledFirebaseAuth from 'react-firebaseui/StyledFirebaseAuth';
import firebase, { uiConfig } from '../firebase/firebase'

import MenuAppBar from '../components/menuappbar';
import SnackBar from '../components/snackbar';

const styles = theme => ({
    // ...
});

class Layout extends React.Component {
    constructor(props) {
        super(props);

        this.state = {
            system: {
                snackbarMessage: "",
                updateSnackbarMessage: this.updateSnackbarMessage,
            },
        };

        firebase.auth().onAuthStateChanged((user) => {
            if (user) {
                // User is signed in.
                console.log("★★ User is signed in:", user);
                this.handleLogin(user);
                this.updateSnackbarMessage("ログインしました");
            } else {
                // User is signed out.
                console.log("★★ User is signed out.");
                this.handleLogout(user);
                // 今回はmenuappbar.jsからSnackbarを更新することが目的のためコメントアウト
                // this.updateSnackbarMessage("ログアウトしました");
            }
        });
    }

    handleLogin = (user) => { this.setState({ user }); }
    handleLogout = () => { this.setState({ user: null }); }
    updateSnackbarMessage = message => {
        this.setState({
            // ...でsnackbarMessage以外の値はそのまま維持
            system: {
                ...this.state.system,
                snackbarMessage: message,
            }
        });
    }

    render() {
        const { classes } = this.props;

        return (
            <SystemContext.Provider value={this.state.system}>
                <UserContext.Provider value={this.state.user}>
                    <MenuAppBar />
                    {
                        !!this.state.user || <StyledFirebaseAuth uiConfig={uiConfig} firebaseAuth={firebase.auth()} />
                    }
                    <SnackBar message={this.state.system.snackbarMessage} />
                </UserContext.Provider>
            </SystemContext.Provider>
        );
    }
}

export default withRoot(withStyles(styles)(Layout));

各コンポーネントで複数のContextをいちいち書かなくて済むよう、HOCとして定義。

src/components/withSystemContext.js
import React from 'react';

import { UserContext } from '../contexts/user'
import { SystemContext } from '../contexts/system'

function withSystemContext(WrappedComponent) {
    return class extends React.Component {
        render() {
            return (
                <SystemContext.Consumer>
                    {system => (
                        <UserContext.Consumer>
                            {user => (
                                // UserContextはuser,SystemContextはsystemとしてpropsに付与
                                <WrappedComponent user={user} system={system} {...this.props} />
                            )}
                        </UserContext.Consumer>
                    )}
                </SystemContext.Consumer>
            );
        }
    };
}

export default withSystemContext;

最後尾のexportのところでHOCでラップしているのがポイント。
そのため、propsからuserやsystemでContextのデータにアクセス可能になる。
一つのコンテキストならMenuAppBar.contextType = UserContext;とやればよかった点を思えば少し面倒。

render()が長いですが、handleClick()でContextのメソッドが実行できるところさえ押さえればOK。

src/components/menuappbar.js
import React from 'react';
import { withStyles } from '@material-ui/core/styles';
import AppBar from '@material-ui/core/AppBar';
import Button from '@material-ui/core/Button';
import Toolbar from '@material-ui/core/Toolbar';
import Typography from '@material-ui/core/Typography';
import IconButton from '@material-ui/core/IconButton';
import MenuIcon from '@material-ui/icons/Menu';
import AccountCircle from '@material-ui/icons/AccountCircle';
import MenuItem from '@material-ui/core/MenuItem';
import Menu from '@material-ui/core/Menu';

import withSystemContext from './WithSystemContext';

import firebase from '../firebase/firebase'

const styles = {
    // ...
};

class MenuAppBar extends React.Component {
  state = {
    anchorEl: null,
  };

  handleMenu = event => {
    this.setState({ anchorEl: event.currentTarget });
  };

  handleClose = () => {
    this.setState({ anchorEl: null });
  };

  handleClick = () => {
    this.handleClose();
    // コールバックでthis.propsを使うためには、()=>{}にする
    firebase.auth().signOut().then(() => {
      // Sign-out successful.
      this.props.system.updateSnackbarMessage("メニューからログアウトを実行。");
    }).catch(function (error) {
      // An error happened.
    });
  };

  render() {
    const { classes, user } = this.props;
    const { anchorEl } = this.state;
    const open = Boolean(anchorEl);

    const auth = !!user;

    return (
      <div className={classes.root}>
        <AppBar position="static">
          <Toolbar>
            <IconButton className={classes.menuButton} color="inherit" aria-label="Menu">
              <MenuIcon />
            </IconButton>
            <Typography variant="h6" color="inherit" className={classes.grow}>
              coming soon...
            </Typography>
            {auth && (
              <div>
                <IconButton
                  aria-owns={open ? 'menu-appbar' : undefined}
                  aria-haspopup="true"
                  onClick={this.handleMenu}
                  color="inherit"
                >
                  <AccountCircle />
                </IconButton>
                <Menu
                  id="menu-appbar"
                  anchorEl={anchorEl}
                  anchorOrigin={{
                    vertical: 'top',
                    horizontal: 'right',
                  }}
                  transformOrigin={{
                    vertical: 'top',
                    horizontal: 'right',
                  }}
                  open={open}
                  onClose={this.handleClose}
                >
                  <MenuItem onClick={this.handleClose}>Profile</MenuItem>
                  <MenuItem onClick={this.handleClose}>My account</MenuItem>
                  <MenuItem onClick={this.handleClick}>Logout</MenuItem>
                </Menu>
              </div>
            )}
            {(!auth) && (
              <div>
                <Button color="inherit">Loginしてください</Button>
              </div>
            )}
          </Toolbar>
        </AppBar>
      </div>
    );
  }
}

export default withStyles(styles)(withSystemContext(MenuAppBar));
src/components/snackbar.js
import React from 'react';
import { withStyles } from '@material-ui/core/styles';
import SnackbarContent from '@material-ui/core/SnackbarContent';

const styles = theme => ({
    // ...
});

function Snackbar(props) {
    const { classes, message } = props;

    return (
        <SnackbarContent className={classes.snackbar} message={message} />
    );
}

export default withStyles(styles)(Snackbar);
src/contexts/user.js
import React from 'react';

const user = null;

export const UserContext = React.createContext(
    user // default value
);

export default UserContext;

子コンポーネントが更新するためのメソッドとして、updateSnackbarMessage()を用意。

src/contexts/system.js
import React from 'react';

export const SystemContext = React.createContext({
    snackbarMessage: "",
    updateSnackbarMessage: () => { },
});

export default SystemContext;

まとめ

Reduxを使わずContextAPIだけで複数のグローバル値の連携ができるようになりました。
シーケンスがシンプルな小規模アプリなら、Reduxなしで行ける目処が付いたといったところでしょうか。
HOCによってConsumerの部分を切り離すことで、冗長なコードを排除し、Reduxへの移行時にも比較的扱いやすくなっているかと思います。
記事化して気になった点としては、
- layout.jsがContextの処理場になっているので、HOC使って分離できないだろうか?
- snackbarのContextをsystemのContextに汎化したが、余計なrenderが走りそうなのでやはりSnackbarContextとして汎化は廃止したほうがよさそう。
- this.props.system.updateSnackbarMessage()としているが、HOCでメソッドラップしてsystemの上書き防止を施したほうが安心感がある。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away