React context withTracker is awesome!

Just started using React in Meteor for a new app, and this thread was very useful.

I’ve modified things to reduce the amount of boilerplate I’d have to write, and this might be a useful addition to the conversation. (Or I might learn something about how I should be doing things).

Basically, I can write components that consume context like this (the context is added to props) and write providers concentrating on just getting the data its schema:

some-component.jsx

import React from 'react';
import PropTypes from 'prop-types';

import { InjectConnectionStatusContextInto, connectionStatusPropTypes } from '/imports/client/providers/connection-status-provider';

export class ConnectionStatusInfo extends React.Component {
	render() {
		const { connected, status, retryCount } = this.props.connectionStatus;
		return (
			<React.Fragment>
				<h3>Connection Information</h3>

				<p>Status: <code>{status}</code></p>

				<p>Connected: <code>{connected.toString()}</code></p>
			</React.Fragment>
		);
	}
}
ConnectionStatusInfo.propTypes = {
	connectionStatus: PropTypes.shape(connectionStatusPropTypes).isRequired,
};

export default InjectConnectionStatusContextInto(ConnectionStatusInfo);

The provider:

import PropTypes from 'prop-types';

import { Meteor } from 'meteor/meteor';

import createReactiveContext from './create-reactive-context';

export const connectionStatusPropTypes = {
	retryCount: PropTypes.number.isRequired,
	status: PropTypes.string.isRequired,
	connected: PropTypes.bool.isRequired,
};

const propTypes = {
	connectionStatus: PropTypes.shape(connectionStatusPropTypes).isRequired,
};

function getProps() {
	return {
		connectionStatus: Meteor.connection.status()
	};
}

export const {
	ConnectionStatusProvider, ConnectionStatusConsumer,
	ConnectionStatusContext, InjectConnectionStatusContextInto
} = createReactiveContext('ConnectionStatus', propTypes, getProps);

I’ve dumped all the boilerplate into a function createReactiveContext which is shown here:

import React from 'react';
import PropTypes from 'prop-types';
import _ from 'underscore';

import { withTracker } from 'meteor/react-meteor-data';

function injectContext(ContextConsumer, WrappedComponent, preferContext) {
	// context overwrites props if preferContext = true
	return props => {
		return (
			<ContextConsumer>
				{context => preferContext ? <WrappedComponent {...props} {...context}/> : <WrappedComponent {...context} {...props}/>}
			</ContextConsumer>
		);
	};
}

export default function createReactiveContext(contextName, propTypes, reactivePropFunction,
	{ preferContext = true, namedComponents = true } = {}
) {
	const Context = React.createContext(contextName);

	class ReactiveProvider extends React.Component {
		render() {
			return (
				<Context.Provider value={this.props}>
					{this.props.children}
				</Context.Provider>
			);
		}
	}

	ReactiveProvider.propTypes = {
		children: PropTypes.node.isRequired
	};
	_.extend(ReactiveProvider.propTypes, propTypes);

	const Provider = withTracker(reactivePropFunction)(ReactiveProvider);
	const Consumer = Context.Consumer;

	const InjectContextInto = Component => injectContext(Consumer, Component, preferContext);

	if (namedComponents) {
		return {
			[`${contextName}Provider`]: Provider,
			[`${contextName}Consumer`]: Consumer,
			[`${contextName}Context`]: Context,
			[`Inject${contextName}ContextInto`]: InjectContextInto,
		};
	} else {
		return { Provider, Consumer, Context, InjectContextInto };
	}
}

… that’s as currently written in my codebase.

Comments very much welcome. (Actually, comments sought… because I’m not sure if this is the best way.)

Last time I tried to use the React Context it did not play very well with React Router - the latter simply silently failed. Any click on a <Link /> changed the address bar but did not render the actual route. No errors anywhere.

Just an FYI for anyone scratching his head because of this.

Just ran into this issue.
I think the issue is update blocking described in react-router guide:

Simple solution for me was to add withRouter to the AccountProvider (using the OP example):

import { withRouter } from 'react-router-dom';

export const AccountProvider = withRouter(withAccount(Provider));

Hello there!

Such a useful and neat pattern you shared @captainn. Thanks for that!

I wonder if you have any caching solution pattern you use that you’d like to share? Or anyone??

At the moment, I’m fetching data from the DB every time I go to a certain page, with React Router, so that definitely requires some improvements and I’m thinking of dropping the router all together. What do you folks think?

I load data into a ground db instance over methods, usually on the container element for a route, in componentDidMount (then the reactive meteor source does its magic to reflow the data). You could also put a timeout on that, so that it would only re-request data after a certain amount of time has passed. You’d have to store that info somewhere, maybe in a separate offline collection, or redux or something. If you use redux or a memory only store, it would allow a refresh of the page to fetch new data.

1 Like

@captainn Awesome concept there! Thank you for sharing the idea! I’ve tried to follow your example, but am facing a problem here. In need of a little help. :pray: :pray:

I have created a similar code with React context, but without the withTracker implementation. As you can see from the code below, the useEffect is triggered when it detects the current user login, and that’s it, no more updates after that. I think this code can benefit more if I can set it up using withTracker, so that whenever there’s a change on the current user collection, it feeds the latest info to the specific components that uses the HOC.

import React, { useContext, useState, useEffect } from 'react';
import { Meteor } from 'meteor/meteor';

import message from 'antd/es/message';

const CurrentUserProfileContext = React.createContext()

export function useCurrentUserProfile() {
    return useContext(CurrentUserProfileContext);
}

export function CurrentUserProfileProvider({children}) {
    const [currentUserData, setCurrentUserData] = useState();
    const [loggedIn, setLoggedIn] = useState(false);

    let userX = Meteor.userId()
    useEffect(() => {
        if(typeof userX == 'string'){
            setLoggedIn(true)
        } else {
            setLoggedIn(false)
        }
    })

    useEffect(() => {
        const loadData = () => {
            Meteor.call('getCurrentUser', function(err, result){
                if(err){
                    message.error('Error getting current user data');
                } else {
                    let userData = result[0];
                    setCurrentUserData(userData);
                }
            });
        }
        loadData();
    }, [loggedIn]);
    
    //useEffect(() => console.log(currentUserData), [currentUserData]);

    return (
        <CurrentUserProfileContext.Provider value={currentUserData}>
            {children}
        </CurrentUserProfileContext.Provider>
    )
}

How can I change this code to use withTracker to always receive latest info?

P/S: For clarity, I’ve created a different collection named UserProfiles to store more data about the user, and assign Meteor.userId to an owner key in each of the UserProfile document as linked identity between the default user collection and the UserProfiles collection.

I suspect this is happening because your first useEffect is only firing on mount and not after that (no dependency array). This means that the second useEffect’s dependency is never changing, hence has no reason to rerun.

Try this:

import React, { useContext, useState, useEffect } from 'react';
import { Meteor } from 'meteor/meteor';

import message from 'antd/es/message';

const CurrentUserProfileContext = React.createContext();

export function useCurrentUserProfile() {
  const context = useContext(CurrentUserProfileContext);
	if (context === undefined) {
		throw new Error(
			`useCurrentUserProfile must be used within a CurrentUserProfileContext Provider`,
		);
	}
	return context;
}

export function CurrentUserProfileProvider({ children }) {
	const [currentUserData, setCurrentUserData] = useState();

	useEffect(() => {
		const loadData = () => {
			Meteor.call('getCurrentUser', function (err, result) {
				if (err) {
					message.error('Error getting current user data');
				} else {
					let userData = result[0];
					setCurrentUserData(userData);
				}
			});
		};
		loadData();
	}, [Meteor.userId()]);

	return (
		<CurrentUserProfileContext.Provider value={currentUserData}>
			{children}
		</CurrentUserProfileContext.Provider>
	);
}

@hemalr87 elegant!

But the collection that gets updated is not the user collection, but some other collection i.e. UserProfile… so, will the Meteor.userId() trigger a rerun, when some other collection is updated?

Ah right.

No it will not. But by the sounds of it, this is something that is made for a subscription rather than a method - because for you to put in the logic to rerun the method call would require knowing that a change has happened, which the client doesn’t know until the method is called.

Yes, that’s something that I would like to achieve with witchTracker and to wrap that in a React context.
Will something like this work?

import React, { useContext, useState, useEffect } from 'react';
import { Meteor } from 'meteor/meteor';
import { UserProfiles } from '../../../api/userprofiles.js';
import message from 'antd/es/message';

const CurrentUserProfileContext = React.createContext();

export function useCurrentUserProfile() {
  const context = useContext(CurrentUserProfileContext);
	if (context === undefined) {
		throw new Error(
			`useCurrentUserProfile must be used within a CurrentUserProfileContext Provider`,
		);
	}
	return context;
}

const CurrentUserProfileProvider = (props, { children }) =>  {
	const [currentUserData, setCurrentUserData] = useState();

	useEffect(() => {
		const loadData = () => {
			Meteor.call('getCurrentUser', function (err, result) {
				if (err) {
					message.error('Error getting current user data');
				} else {
					let userData = result[0];
					setCurrentUserData(userData);
				}
			});
		};
		loadData();
	}, [props]);

	return (
		<CurrentUserProfileContext.Provider value={currentUserData}>
			{children}
		</CurrentUserProfileContext.Provider>
	);
}
export default withTracker(() => {
   const subscribeData = Meteor.subscribe('userprofile');
   const loading = !subscribeData.ready();
   const data = UserProfiles.find();
   const dataExist = !loading && !!data;

   //return data as props
   return {
       loading,
       dataExists,
       actualData: dataExists ? UserProfile.find({
            owner: Meteor.userId()
      }).fetch() : []
}
})(CurrentUserProfileProvider)

That pattern should work in principle yes (minus the typo on dataExist). A few notes/questions:

  • If the ‘getCurrentUser’ method is fetching data from the same collection as the ‘userprofile’ subscription then that method call is not required at all since the tracker will pass through the data directly to the component (making the method call fetching the same data redundant).
  • If you wanted to keep the React hooks pattern going you could useTracker within the component itself vs the withTracker HOC (not that one is necessarily better than the other, just an option).
  • I would avoid using props as your dependency (if that useEffect and the method call inside it are needed at all). Since it isn’t a primitive, that useEffect will rerun every single render. You could either break it down into an array of dependencies or use something like this package.
  • The ternary for your actualData value isn’t necessary either as if data didn’t exist, an empty array would be returned by default.
1 Like

@hemalr87 i’ve cleaned up the code to reflect your suggestions above:

import React, { useContext } from 'react';
import { Meteor } from 'meteor/meteor';
import { withTracker } from 'meteor/react-meteor-data';
import { UserProfiles } from '../imports/api/UserProfiles';

const CurrentUserProfileContext = React.createContext();

export function useCurrentUserProfile() {
  const context = useContext(CurrentUserProfileContext);
	if (context === undefined) {
		throw new Error(
			`useCurrentUserProfile must be used within a CurrentUserProfileContext Provider`,
		);
	}
	return context;
}

function CurrentUserProfileProvider(props, { children }){

	return (
		<CurrentUserProfileContext.Provider value={props.actualData}>
			{children}
		</CurrentUserProfileContext.Provider>
	);
}
export default withTracker(() => {
   const subscribeData = Meteor.subscribe('userprofile');
   const loading = !subscribeData.ready();
   const data = UserProfiles.find();
   const dataExist = !loading && !!data;

   //return data as props
   return {
       loading,
       dataExist,
       actualData: UserProfile.find({
            owner: Meteor.userId()
      }).fetch()
}
})(CurrentUserProfileProvider);

… but am getting an error on the console:

Warning: React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: undefined. You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.

I got it working, for now. Sharing the code for the benefit of others. Not saying this is a clean code, but just want to share the idea:

import React, { useContext, useState, useEffect } from 'react';
import { Meteor } from 'meteor/meteor';
import { withTracker } from 'meteor/react-meteor-data';
import { UserProfiles } from '../imports/api/UserProfiles';

const CurrentUserProfileContext = React.createContext();

export function useCurrentUserProfile() {
    return useContext(CurrentUserProfileContext);
}

const CurrentUserProfileProvider = (props,{ children }) => {
    const [currentUser, setCurrentUser] = useState();
    
    useEffect(() => {
        const getData = () => {
            if (props != undefined){
                setCurrentUser(props.actualData[0])
            }
        }
        getData()
    }),[props]
    
	return (
		<CurrentUserProfileContext.Provider value={currentUser}>
			{props.children}
		</CurrentUserProfileContext.Provider>
	);
}
export default withTracker(() => {
   const subscribeData = Meteor.subscribe('currentUserProfile');
   const loading = !subscribeData.ready();
   const data = UserProfiles.find();
   const dataExist = !loading && !!data;

   //return data as props
   return {
       loading,
       dataExist,
       actualData: UserProfiles.find({
            owner: Meteor.userId()
      }).fetch()
}
})(CurrentUserProfileProvider);

You could put your subscription and data fetching from withTracker in useTracker in your CurrentUserProfileProvider, replacing useEffect and useState there, and then feed that data through the Context API to greatly simplify your work here.

2 Likes

Nice, glad you got it working :+1: