diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
index 33d7cec90ee68..3c7972aff4df8 100644
--- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
@@ -13,12 +13,14 @@
 let React;
 let ReactTestRenderer;
 let ReactDebugTools;
+let act;
 
-describe('ReactHooksInspectionIntergration', () => {
+describe('ReactHooksInspectionIntegration', () => {
   beforeEach(() => {
     jest.resetModules();
     React = require('react');
     ReactTestRenderer = require('react-test-renderer');
+    act = ReactTestRenderer.act;
     ReactDebugTools = require('react-debug-tools');
   });
 
@@ -47,7 +49,7 @@ describe('ReactHooksInspectionIntergration', () => {
       onMouseUp: setStateB,
     } = renderer.root.findByType('div').props;
 
-    setStateA('Hi');
+    act(() => setStateA('Hi'));
 
     childFiber = renderer.root.findByType(Foo)._currentFiber();
     tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -57,7 +59,7 @@ describe('ReactHooksInspectionIntergration', () => {
       {name: 'State', value: 'world', subHooks: []},
     ]);
 
-    setStateB('world!');
+    act(() => setStateB('world!'));
 
     childFiber = renderer.root.findByType(Foo)._currentFiber();
     tree = ReactDebugTools.inspectHooksOfFiber(childFiber);
@@ -91,8 +93,12 @@ describe('ReactHooksInspectionIntergration', () => {
       React.useMemo(() => state1 + state2, [state1]);
 
       function update() {
-        setState('A');
-        dispatch({value: 'B'});
+        act(() => {
+          setState('A');
+        });
+        act(() => {
+          dispatch({value: 'B'});
+        });
         ref.current = 'C';
       }
       let memoizedUpdate = React.useCallback(update, []);
diff --git a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
index bc2ae71673d22..985827e53bfd7 100644
--- a/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
+++ b/packages/react-dom/src/__tests__/ReactDOMSuspensePlaceholder-test.js
@@ -13,7 +13,9 @@ let React;
 let ReactDOM;
 let Suspense;
 let ReactCache;
+let ReactTestUtils;
 let TextResource;
+let act;
 
 describe('ReactDOMSuspensePlaceholder', () => {
   let container;
@@ -23,6 +25,8 @@ describe('ReactDOMSuspensePlaceholder', () => {
     React = require('react');
     ReactDOM = require('react-dom');
     ReactCache = require('react-cache');
+    ReactTestUtils = require('react-dom/test-utils');
+    act = ReactTestUtils.act;
     Suspense = React.Suspense;
     container = document.createElement('div');
     document.body.appendChild(container);
@@ -142,12 +146,14 @@ describe('ReactDOMSuspensePlaceholder', () => {
         );
       }
 
-      ReactDOM.render(, container);
+      act(() => {
+        ReactDOM.render(, container);
+      });
       expect(container.innerHTML).toEqual(
         'SiblingLoading...',
       );
 
-      setIsVisible(true);
+      act(() => setIsVisible(true));
       expect(container.innerHTML).toEqual(
         'SiblingLoading...',
       );
diff --git a/packages/react-dom/src/__tests__/ReactTestUtils-test.js b/packages/react-dom/src/__tests__/ReactTestUtils-test.js
index 687dbd1aaec2c..49d64fe3ba2bb 100644
--- a/packages/react-dom/src/__tests__/ReactTestUtils-test.js
+++ b/packages/react-dom/src/__tests__/ReactTestUtils-test.js
@@ -14,6 +14,7 @@ let React;
 let ReactDOM;
 let ReactDOMServer;
 let ReactTestUtils;
+let act;
 
 function getTestDocument(markup) {
   const doc = document.implementation.createHTMLDocument('');
@@ -33,6 +34,7 @@ describe('ReactTestUtils', () => {
     ReactDOM = require('react-dom');
     ReactDOMServer = require('react-dom/server');
     ReactTestUtils = require('react-dom/test-utils');
+    act = ReactTestUtils.act;
   });
 
   it('Simulate should have locally attached media events', () => {
@@ -515,4 +517,168 @@ describe('ReactTestUtils', () => {
     ReactTestUtils.renderIntoDocument();
     expect(mockArgs.length).toEqual(0);
   });
+
+  it('can use act to batch effects', () => {
+    function App(props) {
+      React.useEffect(props.callback);
+      return null;
+    }
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+
+    try {
+      let called = false;
+      act(() => {
+        ReactDOM.render(
+           {
+              called = true;
+            }}
+          />,
+          container,
+        );
+      });
+
+      expect(called).toBe(true);
+    } finally {
+      document.body.removeChild(container);
+    }
+  });
+
+  it('flushes effects on every call', () => {
+    function App(props) {
+      let [ctr, setCtr] = React.useState(0);
+      React.useEffect(() => {
+        props.callback(ctr);
+      });
+      return (
+        
+      );
+    }
+
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    let calledCtr = 0;
+    act(() => {
+      ReactDOM.render(
+         {
+            calledCtr = val;
+          }}
+        />,
+        container,
+      );
+    });
+    const button = document.getElementById('button');
+    function click() {
+      button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+    }
+
+    act(() => {
+      click();
+      click();
+      click();
+    });
+    expect(calledCtr).toBe(3);
+    act(click);
+    expect(calledCtr).toBe(4);
+    act(click);
+    expect(calledCtr).toBe(5);
+
+    document.body.removeChild(container);
+  });
+
+  it('can use act to batch effects on updates too', () => {
+    function App() {
+      let [ctr, setCtr] = React.useState(0);
+      return (
+        
+      );
+    }
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    let button;
+    act(() => {
+      ReactDOM.render(, container);
+    });
+    button = document.getElementById('button');
+    expect(button.innerHTML).toBe('0');
+    act(() => {
+      button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+    });
+    expect(button.innerHTML).toBe('1');
+    document.body.removeChild(container);
+  });
+
+  it('detects setState being called outside of act(...)', () => {
+    let setValueRef = null;
+    function App() {
+      let [value, setValue] = React.useState(0);
+      setValueRef = setValue;
+      return (
+        
+      );
+    }
+    const container = document.createElement('div');
+    document.body.appendChild(container);
+    let button;
+    act(() => {
+      ReactDOM.render(, container);
+      button = container.querySelector('#button');
+      button.dispatchEvent(new MouseEvent('click', {bubbles: true}));
+    });
+    expect(button.innerHTML).toBe('2');
+    expect(() => setValueRef(1)).toWarnDev(
+      ['An update to App inside a test was not wrapped in act(...).'],
+      {withoutStack: 1},
+    );
+    document.body.removeChild(container);
+  });
+
+  it('lets a ticker update', () => {
+    function App() {
+      let [toggle, setToggle] = React.useState(0);
+      React.useEffect(() => {
+        let timeout = setTimeout(() => {
+          setToggle(1);
+        }, 200);
+        return () => clearTimeout(timeout);
+      });
+      return toggle;
+    }
+    const container = document.createElement('div');
+
+    act(() => {
+      act(() => {
+        ReactDOM.render(, container);
+      });
+      jest.advanceTimersByTime(250);
+    });
+
+    expect(container.innerHTML).toBe('1');
+  });
+
+  it('warns if you return a value inside act', () => {
+    expect(() => act(() => 123)).toWarnDev(
+      [
+        'The callback passed to ReactTestUtils.act(...) function must not return anything.',
+      ],
+      {withoutStack: true},
+    );
+  });
+
+  it('warns if you try to await an .act call', () => {
+    expect(act(() => {}).then).toWarnDev(
+      [
+        'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
+      ],
+      {withoutStack: true},
+    );
+  });
 });
diff --git a/packages/react-dom/src/test-utils/ReactTestUtils.js b/packages/react-dom/src/test-utils/ReactTestUtils.js
index b596a1cff58f4..5bf6755bbbd62 100644
--- a/packages/react-dom/src/test-utils/ReactTestUtils.js
+++ b/packages/react-dom/src/test-utils/ReactTestUtils.js
@@ -18,9 +18,15 @@ import {
 import SyntheticEvent from 'events/SyntheticEvent';
 import invariant from 'shared/invariant';
 import lowPriorityWarning from 'shared/lowPriorityWarning';
+import warningWithoutStack from 'shared/warningWithoutStack';
 import {ELEMENT_NODE} from '../shared/HTMLNodeType';
 import * as DOMTopLevelEventTypes from '../events/DOMTopLevelEventTypes';
 
+// for .act's return value
+type Thenable = {
+  then(resolve: () => mixed, reject?: () => mixed): mixed,
+};
+
 const {findDOMNode} = ReactDOM;
 // Keep in sync with ReactDOMUnstableNativeDependencies.js
 // and ReactDOM.js:
@@ -145,6 +151,9 @@ function validateClassInstance(inst, methodName) {
   );
 }
 
+// stub element used by act() when flushing effects
+let actContainerElement = document.createElement('div');
+
 /**
  * Utilities for making it easy to test React components.
  *
@@ -380,6 +389,43 @@ const ReactTestUtils = {
 
   Simulate: null,
   SimulateNative: {},
+
+  act(callback: () => void): Thenable {
+    // note: keep these warning messages in sync with
+    // createReactNoop.js and ReactTestRenderer.js
+    const result = ReactDOM.unstable_batchedUpdates(callback);
+    if (__DEV__) {
+      if (result !== undefined) {
+        let addendum;
+        if (typeof result.then === 'function') {
+          addendum =
+            '\n\nIt looks like you wrote ReactTestUtils.act(async () => ...), ' +
+            'or returned a Promise from the callback passed to it. ' +
+            'Putting asynchronous logic inside ReactTestUtils.act(...) is not supported.\n';
+        } else {
+          addendum = ' You returned: ' + result;
+        }
+        warningWithoutStack(
+          false,
+          'The callback passed to ReactTestUtils.act(...) function must not return anything.%s',
+          addendum,
+        );
+      }
+    }
+    ReactDOM.render(, actContainerElement);
+    // we want the user to not expect a return,
+    // but we want to warn if they use it like they can await on it.
+    return {
+      then() {
+        if (__DEV__) {
+          warningWithoutStack(
+            false,
+            'Do not await the result of calling ReactTestUtils.act(...), it is not a Promise.',
+          );
+        }
+      },
+    };
+  },
 };
 
 /**
diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js
index 25d2722f5764c..491ab5948ba0b 100644
--- a/packages/react-noop-renderer/src/createReactNoop.js
+++ b/packages/react-noop-renderer/src/createReactNoop.js
@@ -21,6 +21,12 @@ import type {ReactNodeList} from 'shared/ReactTypes';
 import {createPortal} from 'shared/ReactPortal';
 import expect from 'expect';
 import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
+import warningWithoutStack from 'shared/warningWithoutStack';
+
+// for .act's return value
+type Thenable = {
+  then(resolve: () => mixed, reject?: () => mixed): mixed,
+};
 
 type Container = {
   rootID: string,
@@ -864,6 +870,43 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
 
     interactiveUpdates: NoopRenderer.interactiveUpdates,
 
+    // maybe this should exist only in the test file
+    act(callback: () => void): Thenable {
+      // note: keep these warning messages in sync with
+      // ReactTestRenderer.js and ReactTestUtils.js
+      let result = NoopRenderer.batchedUpdates(callback);
+      if (__DEV__) {
+        if (result !== undefined) {
+          let addendum;
+          if (typeof result.then === 'function') {
+            addendum =
+              "\n\nIt looks like you wrote ReactNoop.act(async () => ...) or returned a Promise from it's callback. " +
+              'Putting asynchronous logic inside ReactNoop.act(...) is not supported.\n';
+          } else {
+            addendum = ' You returned: ' + result;
+          }
+          warningWithoutStack(
+            false,
+            'The callback passed to ReactNoop.act(...) function must not return anything.%s',
+            addendum,
+          );
+        }
+      }
+      ReactNoop.flushPassiveEffects();
+      // we want the user to not expect a return,
+      // but we want to warn if they use it like they can await on it.
+      return {
+        then() {
+          if (__DEV__) {
+            warningWithoutStack(
+              false,
+              'Do not await the result of calling ReactNoop.act(...), it is not a Promise.',
+            );
+          }
+        },
+      };
+    },
+
     flushSync(fn: () => mixed) {
       yieldedValues = [];
       NoopRenderer.flushSync(fn);
diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js
index fef20fd1ec07c..1443fb544cdd1 100644
--- a/packages/react-reconciler/src/ReactFiberHooks.js
+++ b/packages/react-reconciler/src/ReactFiberHooks.js
@@ -30,6 +30,7 @@ import {
 } from './ReactHookEffectTags';
 import {
   scheduleWork,
+  warnIfNotCurrentlyBatchingInDev,
   computeExpirationForFiber,
   flushPassiveEffects,
   requestCurrentTime,
@@ -1003,6 +1004,19 @@ function updateMemo(
   return nextValue;
 }
 
+// in a test-like environment, we want to warn if dispatchAction()
+// is called outside of a batchedUpdates/TestUtils.act(...) call.
+let shouldWarnForUnbatchedSetState = false;
+
+if (__DEV__) {
+  // jest isnt' a 'global', it's just exposed to tests via a wrapped function
+  // further, this isn't a test file, so flow doesn't recognize the symbol. So...
+  // $FlowExpectedError - because requirements don't give a damn about your type sigs.
+  if ('undefined' !== typeof jest) {
+    shouldWarnForUnbatchedSetState = true;
+  }
+}
+
 function dispatchAction(
   fiber: Fiber,
   queue: UpdateQueue,
@@ -1121,6 +1135,11 @@ function dispatchAction(
         }
       }
     }
+    if (__DEV__) {
+      if (shouldWarnForUnbatchedSetState === true) {
+        warnIfNotCurrentlyBatchingInDev(fiber);
+      }
+    }
     scheduleWork(fiber, expirationTime);
   }
 }
diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js
index 928a3616a0021..5712dbc9cd6d1 100644
--- a/packages/react-reconciler/src/ReactFiberScheduler.js
+++ b/packages/react-reconciler/src/ReactFiberScheduler.js
@@ -1790,6 +1790,25 @@ function scheduleWorkToRoot(fiber: Fiber, expirationTime): FiberRoot | null {
   return root;
 }
 
+export function warnIfNotCurrentlyBatchingInDev(fiber: Fiber): void {
+  if (__DEV__) {
+    if (isRendering === false && isBatchingUpdates === false) {
+      warningWithoutStack(
+        false,
+        'An update to %s inside a test was not wrapped in act(...).\n\n' +
+          'When testing, code that causes React state updates should be wrapped into act(...):\n\n' +
+          'act(() => {\n' +
+          '  /* fire events that update state */\n' +
+          '});\n' +
+          '/* assert on the output */\n\n' +
+          "This ensures that you're testing the behavior the user would see in the browser." +
+          ' Learn more at https://fb.me/react-wrap-tests-with-act',
+        getComponentName(fiber.type),
+      );
+    }
+  }
+}
+
 function scheduleWork(fiber: Fiber, expirationTime: ExpirationTime) {
   const root = scheduleWorkToRoot(fiber, expirationTime);
   if (root === null) {
diff --git a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
index 6a8924e668a3d..ec5ca92aa060b 100644
--- a/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactHooks-test.internal.js
@@ -16,6 +16,7 @@ let React;
 let ReactFeatureFlags;
 let ReactTestRenderer;
 let ReactDOMServer;
+let act;
 
 // Additional tests can be found in ReactHooksWithNoopRenderer. Plan is to
 // gradually migrate those to this file.
@@ -28,6 +29,7 @@ describe('ReactHooks', () => {
     React = require('react');
     ReactTestRenderer = require('react-test-renderer');
     ReactDOMServer = require('react-dom/server');
+    act = ReactTestRenderer.act;
   });
 
   if (__DEV__) {
@@ -81,8 +83,11 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('0, 0');
 
     // Normal update
-    setCounter1(1);
-    setCounter2(1);
+    act(() => {
+      setCounter1(1);
+      setCounter2(1);
+    });
+
     expect(root).toFlushAndYield([
       'Parent: 1, 1',
       'Child: 1, 1',
@@ -90,13 +95,16 @@ describe('ReactHooks', () => {
     ]);
 
     // Update that bails out.
-    setCounter1(1);
+    act(() => setCounter1(1));
     expect(root).toFlushAndYield(['Parent: 1, 1']);
 
     // This time, one of the state updates but the other one doesn't. So we
     // can't bail out.
-    setCounter1(1);
-    setCounter2(2);
+    act(() => {
+      setCounter1(1);
+      setCounter2(2);
+    });
+
     expect(root).toFlushAndYield([
       'Parent: 1, 2',
       'Child: 1, 2',
@@ -104,20 +112,24 @@ describe('ReactHooks', () => {
     ]);
 
     // Lots of updates that eventually resolve to the current values.
-    setCounter1(9);
-    setCounter2(3);
-    setCounter1(4);
-    setCounter2(7);
-    setCounter1(1);
-    setCounter2(2);
+    act(() => {
+      setCounter1(9);
+      setCounter2(3);
+      setCounter1(4);
+      setCounter2(7);
+      setCounter1(1);
+      setCounter2(2);
+    });
 
     // Because the final values are the same as the current values, the
     // component bails out.
     expect(root).toFlushAndYield(['Parent: 1, 2']);
 
     // prepare to check SameValue
-    setCounter1(0 / -1);
-    setCounter2(NaN);
+    act(() => {
+      setCounter1(0 / -1);
+      setCounter2(NaN);
+    });
     expect(root).toFlushAndYield([
       'Parent: 0, NaN',
       'Child: 0, NaN',
@@ -125,14 +137,19 @@ describe('ReactHooks', () => {
     ]);
 
     // check if re-setting to negative 0 / NaN still bails out
-    setCounter1(0 / -1);
-    setCounter2(NaN);
-    setCounter2(Infinity);
-    setCounter2(NaN);
+    act(() => {
+      setCounter1(0 / -1);
+      setCounter2(NaN);
+      setCounter2(Infinity);
+      setCounter2(NaN);
+    });
+
     expect(root).toFlushAndYield(['Parent: 0, NaN']);
 
     // check if changing negative 0 to positive 0 does not bail out
-    setCounter1(0);
+    act(() => {
+      setCounter1(0);
+    });
     expect(root).toFlushAndYield([
       'Parent: 0, NaN',
       'Child: 0, NaN',
@@ -172,21 +189,27 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('0, 0 (light)');
 
     // Normal update
-    setCounter1(1);
-    setCounter2(1);
+    act(() => {
+      setCounter1(1);
+      setCounter2(1);
+    });
+
     expect(root).toFlushAndYield([
       'Parent: 1, 1 (light)',
       'Child: 1, 1 (light)',
     ]);
 
     // Update that bails out.
-    setCounter1(1);
+    act(() => setCounter1(1));
     expect(root).toFlushAndYield(['Parent: 1, 1 (light)']);
 
     // This time, one of the state updates but the other one doesn't. So we
     // can't bail out.
-    setCounter1(1);
-    setCounter2(2);
+    act(() => {
+      setCounter1(1);
+      setCounter2(2);
+    });
+
     expect(root).toFlushAndYield([
       'Parent: 1, 2 (light)',
       'Child: 1, 2 (light)',
@@ -194,14 +217,20 @@ describe('ReactHooks', () => {
 
     // Updates bail out, but component still renders because props
     // have changed
-    setCounter1(1);
-    setCounter2(2);
+    act(() => {
+      setCounter1(1);
+      setCounter2(2);
+    });
+
     root.update();
     expect(root).toFlushAndYield(['Parent: 1, 2 (dark)', 'Child: 1, 2 (dark)']);
 
     // Both props and state bail out
-    setCounter1(1);
-    setCounter2(2);
+    act(() => {
+      setCounter1(1);
+      setCounter2(2);
+    });
+
     root.update();
     expect(root).toFlushAndYield(['Parent: 1, 2 (dark)']);
   });
@@ -224,9 +253,11 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('0');
 
     expect(() => {
-      setCounter(1, () => {
-        throw new Error('Expected to ignore the callback.');
-      });
+      act(() =>
+        setCounter(1, () => {
+          throw new Error('Expected to ignore the callback.');
+        }),
+      );
     }).toWarnDev(
       'State updates from the useState() and useReducer() Hooks ' +
         "don't support the second callback argument. " +
@@ -256,9 +287,11 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('0');
 
     expect(() => {
-      dispatch(1, () => {
-        throw new Error('Expected to ignore the callback.');
-      });
+      act(() =>
+        dispatch(1, () => {
+          throw new Error('Expected to ignore the callback.');
+        }),
+      );
     }).toWarnDev(
       'State updates from the useState() and useReducer() Hooks ' +
         "don't support the second callback argument. " +
@@ -326,7 +359,7 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('0 (light)');
 
     // Normal update
-    setCounter(1);
+    act(() => setCounter(1));
     expect(root).toFlushAndYield([
       'Parent: 1 (light)',
       'Child: 1 (light)',
@@ -335,14 +368,17 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('1 (light)');
 
     // Update that doesn't change state, so it bails out
-    setCounter(1);
+    act(() => setCounter(1));
     expect(root).toFlushAndYield(['Parent: 1 (light)']);
     expect(root).toMatchRenderedOutput('1 (light)');
 
     // Update that doesn't change state, but the context changes, too, so it
     // can't bail out
-    setCounter(1);
-    setTheme('dark');
+    act(() => {
+      setCounter(1);
+      setTheme('dark');
+    });
+
     expect(root).toFlushAndYield([
       'Theme: dark',
       'Parent: 1 (dark)',
@@ -377,7 +413,7 @@ describe('ReactHooks', () => {
     expect(root).toMatchRenderedOutput('0');
 
     // Normal update
-    setCounter(1);
+    act(() => setCounter(1));
     expect(root).toFlushAndYield(['Parent: 1', 'Child: 1', 'Effect: 1']);
     expect(root).toMatchRenderedOutput('1');
 
@@ -385,38 +421,47 @@ describe('ReactHooks', () => {
     // because the alterate fiber has pending update priority, so we have to
     // enter the render phase before we can bail out. But we bail out before
     // rendering the child, and we don't fire any effects.
-    setCounter(1);
+    act(() => setCounter(1));
     expect(root).toFlushAndYield(['Parent: 1']);
     expect(root).toMatchRenderedOutput('1');
 
     // Update to the same state again. This times, neither fiber has pending
     // update priority, so we can bail out before even entering the render phase.
-    setCounter(1);
+    act(() => setCounter(1));
     expect(root).toFlushAndYield([]);
     expect(root).toMatchRenderedOutput('1');
 
     // This changes the state to something different so it renders normally.
-    setCounter(2);
+    act(() => setCounter(2));
     expect(root).toFlushAndYield(['Parent: 2', 'Child: 2', 'Effect: 2']);
     expect(root).toMatchRenderedOutput('2');
 
     // prepare to check SameValue
-    setCounter(0);
+    act(() => {
+      setCounter(0);
+    });
     expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
     expect(root).toMatchRenderedOutput('0');
 
     // Update to the same state for the first time to flush the queue
-    setCounter(0);
+    act(() => {
+      setCounter(0);
+    });
+
     expect(root).toFlushAndYield(['Parent: 0']);
     expect(root).toMatchRenderedOutput('0');
 
     // Update again to the same state. Should bail out.
-    setCounter(0);
+    act(() => {
+      setCounter(0);
+    });
     expect(root).toFlushAndYield([]);
     expect(root).toMatchRenderedOutput('0');
 
     // Update to a different state (positive 0 to negative 0)
-    setCounter(0 / -1);
+    act(() => {
+      setCounter(0 / -1);
+    });
     expect(root).toFlushAndYield(['Parent: 0', 'Child: 0', 'Effect: 0']);
     expect(root).toMatchRenderedOutput('0');
   });
@@ -450,12 +495,14 @@ describe('ReactHooks', () => {
         return value;
       });
     };
-    update(0);
-    update(0);
-    update(0);
-    update(1);
-    update(2);
-    update(3);
+    act(() => {
+      update(0);
+      update(0);
+      update(0);
+      update(1);
+      update(2);
+      update(3);
+    });
 
     expect(ReactTestRenderer).toHaveYielded([
       // The first four updates were eagerly computed, because the queue is
@@ -511,7 +558,7 @@ describe('ReactHooks', () => {
     };
 
     // Update at normal priority
-    update(n => n * 100);
+    act(() => update(n => n * 100));
 
     // The new state is eagerly computed.
     expect(ReactTestRenderer).toHaveYielded(['Compute state (1 -> 100)']);
@@ -839,9 +886,11 @@ describe('ReactHooks', () => {
 
     class Cls extends React.Component {
       render() {
-        _setState(() => {
-          ReactCurrentDispatcher.current.readContext(ThemeContext);
-        });
+        act(() =>
+          _setState(() => {
+            ReactCurrentDispatcher.current.readContext(ThemeContext);
+          }),
+        );
         return null;
       }
     }
@@ -853,7 +902,13 @@ describe('ReactHooks', () => {
           
         ,
       ),
-    ).toWarnDev('Context can only be read while React is rendering');
+    ).toWarnDev(
+      [
+        'Context can only be read while React is rendering',
+        'Render methods should be a pure function of props and state',
+      ],
+      {withoutStack: 1},
+    );
   });
 
   it('warns when calling hooks inside useReducer', () => {
@@ -1294,9 +1349,11 @@ describe('ReactHooks', () => {
     }
 
     function B() {
-      _setState(() => {
-        throw new Error('Hello');
-      });
+      act(() =>
+        _setState(() => {
+          throw new Error('Hello');
+        }),
+      );
       return null;
     }
 
diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
index f5a08478d995c..446dd1bf7e78d 100644
--- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js
@@ -26,6 +26,7 @@ let useRef;
 let useImperativeHandle;
 let forwardRef;
 let memo;
+let act;
 
 // These tests use React Noop Renderer. All new tests should use React Test
 // Renderer and go in ReactHooks-test; plan is gradually migrate the noop tests
@@ -50,6 +51,7 @@ describe('ReactHooksWithNoopRenderer', () => {
     useImperativeHandle = React.useImperativeHandle;
     forwardRef = React.forwardRef;
     memo = React.memo;
+    act = ReactNoop.act;
   });
 
   function span(prop) {
@@ -76,8 +78,11 @@ describe('ReactHooksWithNoopRenderer', () => {
     expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
     // Schedule some updates
-    counter.current.updateCount(1);
-    counter.current.updateCount(count => count + 10);
+    act(() => {
+      counter.current.updateCount(1);
+      counter.current.updateCount(count => count + 10);
+    });
+
     // Partially flush without committing
     ReactNoop.flushThrough(['Count: 11']);
     expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
@@ -157,11 +162,11 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
-      counter.current.updateCount(1);
+      act(() => counter.current.updateCount(1));
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
 
-      counter.current.updateCount(count => count + 10);
+      act(() => counter.current.updateCount(count => count + 10));
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
     });
@@ -181,7 +186,7 @@ describe('ReactHooksWithNoopRenderer', () => {
       expect(ReactNoop.flush()).toEqual(['getInitialState', 'Count: 42']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]);
 
-      counter.current.updateCount(7);
+      act(() => counter.current.updateCount(7));
       expect(ReactNoop.flush()).toEqual(['Count: 7']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]);
     });
@@ -199,10 +204,10 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
-      counter.current.updateCount(7);
+      act(() => counter.current.updateCount(7));
       expect(ReactNoop.flush()).toEqual(['Count: 7']);
 
-      counter.current.updateLabel('Total');
+      act(() => counter.current.updateLabel('Total'));
       expect(ReactNoop.flush()).toEqual(['Total: 7']);
     });
 
@@ -217,11 +222,11 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
-      updaters[0](1);
+      act(() => updaters[0](1));
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
 
-      updaters[0](count => count + 10);
+      act(() => updaters[0](count => count + 10));
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
 
@@ -240,7 +245,7 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flush();
       ReactNoop.render(null);
       ReactNoop.flush();
-      expect(() => _updateCount(1)).toWarnDev(
+      expect(() => act(() => _updateCount(1))).toWarnDev(
         "Warning: Can't perform a React state update on an unmounted " +
           'component. This is a no-op, but it indicates a memory leak in your ' +
           'application. To fix, cancel all subscriptions and asynchronous ' +
@@ -266,7 +271,7 @@ describe('ReactHooksWithNoopRenderer', () => {
       expect(ReactNoop.flush()).toEqual([]);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
-      _updateCount(1);
+      act(() => _updateCount(1));
       expect(ReactNoop.flush()).toEqual(['Count: 1']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
     });
@@ -490,13 +495,15 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
-      counter.current.dispatch(INCREMENT);
+      act(() => counter.current.dispatch(INCREMENT));
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]);
+      act(() => {
+        counter.current.dispatch(DECREMENT);
+        counter.current.dispatch(DECREMENT);
+        counter.current.dispatch(DECREMENT);
+      });
 
-      counter.current.dispatch(DECREMENT);
-      counter.current.dispatch(DECREMENT);
-      counter.current.dispatch(DECREMENT);
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]);
     });
@@ -530,13 +537,16 @@ describe('ReactHooksWithNoopRenderer', () => {
       expect(ReactNoop.flush()).toEqual(['Init', 'Count: 10']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]);
 
-      counter.current.dispatch(INCREMENT);
+      act(() => counter.current.dispatch(INCREMENT));
       expect(ReactNoop.flush()).toEqual(['Count: 11']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]);
 
-      counter.current.dispatch(DECREMENT);
-      counter.current.dispatch(DECREMENT);
-      counter.current.dispatch(DECREMENT);
+      act(() => {
+        counter.current.dispatch(DECREMENT);
+        counter.current.dispatch(DECREMENT);
+        counter.current.dispatch(DECREMENT);
+      });
+
       expect(ReactNoop.flush()).toEqual(['Count: 8']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]);
     });
@@ -562,9 +572,12 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flush();
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
-      counter.current.dispatch(INCREMENT);
-      counter.current.dispatch(INCREMENT);
-      counter.current.dispatch(INCREMENT);
+      act(() => {
+        counter.current.dispatch(INCREMENT);
+        counter.current.dispatch(INCREMENT);
+        counter.current.dispatch(INCREMENT);
+      });
+
       ReactNoop.flushSync(() => {
         counter.current.dispatch(INCREMENT);
       });
@@ -647,15 +660,20 @@ describe('ReactHooksWithNoopRenderer', () => {
         });
         return ;
       }
+
       ReactNoop.render([, ]);
-      expect(ReactNoop.flush()).toEqual([
-        'Passive',
-        'Layout',
-        'Layout effect 0',
-        'Passive effect',
-        'Layout',
-        'Layout effect 1',
-      ]);
+
+      act(() => {
+        expect(ReactNoop.flush()).toEqual([
+          'Passive',
+          'Layout',
+          'Layout effect 0',
+          'Passive effect',
+          'Layout',
+          'Layout effect 1',
+        ]);
+      });
+
       expect(ReactNoop.getChildren()).toEqual([
         span('Passive'),
         span('Layout'),
@@ -779,7 +797,10 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.flushThrough(['Schedule update [0]', 'Count: 0']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]);
 
-      expect(ReactNoop.flush()).toEqual([]);
+      ReactNoop.batchedUpdates(() => {
+        expect(ReactNoop.flush()).toEqual([]);
+      });
+
       expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]);
 
       ReactNoop.flushPassiveEffects();
@@ -805,7 +826,7 @@ describe('ReactHooksWithNoopRenderer', () => {
 
       // Enqueuing this update forces the passive effect to be flushed --
       // updateCount(1) happens first, so 2 wins.
-      _updateCount(2);
+      act(() => _updateCount(2));
       expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
     });
@@ -851,7 +872,7 @@ describe('ReactHooksWithNoopRenderer', () => {
 
       // Enqueuing this update forces the passive effect to be flushed --
       // updateCount(1) happens first, so 2 wins.
-      _updateCount(2);
+      act(() => _updateCount(2));
       expect(ReactNoop.flush()).toEqual(['Will set count to 1', 'Count: 2']);
       expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]);
 
@@ -1368,7 +1389,7 @@ describe('ReactHooksWithNoopRenderer', () => {
         span('Count: 0'),
       ]);
 
-      button.current.increment();
+      act(button.current.increment);
       expect(ReactNoop.flush()).toEqual([
         // Button should not re-render, because its props haven't changed
         // 'Increment',
@@ -1392,7 +1413,7 @@ describe('ReactHooksWithNoopRenderer', () => {
       ]);
 
       // Callback should have updated
-      button.current.increment();
+      act(button.current.increment);
       expect(ReactNoop.flush()).toEqual(['Count: 11']);
       expect(ReactNoop.getChildren()).toEqual([
         span('Increment'),
@@ -1600,8 +1621,11 @@ describe('ReactHooksWithNoopRenderer', () => {
         span('A: 0, B: 0, C: [not loaded]'),
       ]);
 
-      updateA(2);
-      updateB(3);
+      act(() => {
+        updateA(2);
+        updateB(3);
+      });
+
       expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: [not loaded]']);
       expect(ReactNoop.getChildren()).toEqual([
         span('A: 2, B: 3, C: [not loaded]'),
@@ -1646,10 +1670,11 @@ describe('ReactHooksWithNoopRenderer', () => {
       ReactNoop.render();
       expect(ReactNoop.flush()).toEqual(['A: 0, B: 0, C: 0']);
       expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]);
-
-      updateA(2);
-      updateB(3);
-      updateC(4);
+      act(() => {
+        updateA(2);
+        updateB(3);
+        updateC(4);
+      });
       expect(ReactNoop.flush()).toEqual(['A: 2, B: 3, C: 4']);
       expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]);
       ReactNoop.render();
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
index 73a55370a187c..69e4135cc70ed 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js
@@ -3,6 +3,7 @@ let ReactTestRenderer;
 let ReactFeatureFlags;
 let ReactCache;
 let Suspense;
+let act;
 
 // let JestReact;
 
@@ -19,6 +20,7 @@ describe('ReactSuspense', () => {
     ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false;
     React = require('react');
     ReactTestRenderer = require('react-test-renderer');
+    act = ReactTestRenderer.act;
     // JestReact = require('jest-react');
     ReactCache = require('react-cache');
 
@@ -797,7 +799,7 @@ describe('ReactSuspense', () => {
       ]);
       expect(root).toMatchRenderedOutput('Tab: 0 + sibling');
 
-      setTab(1);
+      act(() => setTab(1));
       expect(ReactTestRenderer).toHaveYielded([
         'Suspend! [Tab: 1]',
         ' + sibling',
@@ -811,7 +813,7 @@ describe('ReactSuspense', () => {
       ]);
       expect(root).toMatchRenderedOutput('Tab: 1 + sibling');
 
-      setTab(2);
+      act(() => setTab(2));
       expect(ReactTestRenderer).toHaveYielded([
         'Suspend! [Tab: 2]',
         ' + sibling',
@@ -864,7 +866,7 @@ describe('ReactSuspense', () => {
       ]);
       expect(root).toMatchRenderedOutput('A:0');
 
-      setStep(1);
+      act(() => setStep(1));
       expect(ReactTestRenderer).toHaveYielded(['Suspend! [A:1]', 'Loading...']);
       expect(root).toMatchRenderedOutput('Loading...');
 
diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
index ed3d9322ba1c4..9444bdbf6f019 100644
--- a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
+++ b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js
@@ -158,7 +158,7 @@ describe('ReactSuspenseFuzz', () => {
         if ((elapsedTime += 1000) > 1000000) {
           throw new Error('Something did not resolve properly.');
         }
-        jest.advanceTimersByTime(1000);
+        ReactTestRenderer.act(() => jest.advanceTimersByTime(1000));
         root.unstable_flushAll();
       }
 
diff --git a/packages/react-test-renderer/src/ReactTestRenderer.js b/packages/react-test-renderer/src/ReactTestRenderer.js
index fda60e171f460..e3b911f95843a 100644
--- a/packages/react-test-renderer/src/ReactTestRenderer.js
+++ b/packages/react-test-renderer/src/ReactTestRenderer.js
@@ -17,8 +17,8 @@ import {
   updateContainer,
   flushSync,
   injectIntoDevTools,
+  batchedUpdates,
 } from 'react-reconciler/inline.test';
-import {batchedUpdates} from 'events/ReactGenericBatching';
 import {findCurrentFiberUsingSlowPath} from 'react-reconciler/reflection';
 import {
   Fragment,
@@ -39,6 +39,7 @@ import {
 } from 'shared/ReactWorkTags';
 import invariant from 'shared/invariant';
 import ReactVersion from 'shared/ReactVersion';
+import warningWithoutStack from 'shared/warningWithoutStack';
 
 import {getPublicInstance} from './ReactTestHostConfig';
 import {
@@ -70,6 +71,11 @@ type FindOptions = $Shape<{
 
 export type Predicate = (node: ReactTestInstance) => ?boolean;
 
+// for .act's return value
+type Thenable = {
+  then(resolve: () => mixed, reject?: () => mixed): mixed,
+};
+
 const defaultTestOptions = {
   createNodeMock: function() {
     return null;
@@ -557,8 +563,61 @@ const ReactTestRendererFiber = {
   /* eslint-enable camelcase */
 
   unstable_setNowImplementation: setNowImplementation,
+
+  act(callback: () => void): Thenable {
+    // note: keep these warning messages in sync with
+    // createNoop.js and ReactTestUtils.js
+    let result = batchedUpdates(callback);
+    if (__DEV__) {
+      if (result !== undefined) {
+        let addendum;
+        if (typeof result.then === 'function') {
+          addendum =
+            "\n\nIt looks like you wrote TestRenderer.act(async () => ...) or returned a Promise from it's callback. " +
+            'Putting asynchronous logic inside TestRenderer.act(...) is not supported.\n';
+        } else {
+          addendum = ' You returned: ' + result;
+        }
+        warningWithoutStack(
+          false,
+          'The callback passed to TestRenderer.act(...) function must not return anything.%s',
+          addendum,
+        );
+      }
+    }
+    flushPassiveEffects();
+    // we want the user to not expect a return,
+    // but we want to warn if they use it like they can await on it.
+    return {
+      then() {
+        if (__DEV__) {
+          warningWithoutStack(
+            false,
+            'Do not await the result of calling TestRenderer.act(...), it is not a Promise.',
+          );
+        }
+      },
+    };
+  },
 };
 
+// root used to flush effects during .act() calls
+const actRoot = createContainer(
+  {
+    children: [],
+    createNodeMock: defaultTestOptions.createNodeMock,
+    tag: 'CONTAINER',
+  },
+  true,
+  false,
+);
+
+function flushPassiveEffects() {
+  // Trick to flush passive effects without exposing an internal API:
+  // Create a throwaway root and schedule a dummy update on it.
+  updateContainer(null, actRoot, null, null);
+}
+
 const fiberToWrapper = new WeakMap();
 function wrapFiber(fiber: Fiber): ReactTestInstance {
   let wrapper = fiberToWrapper.get(fiber);
diff --git a/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js b/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
index 8240fbe75a8a4..0ca6fb54b50a9 100644
--- a/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
+++ b/packages/react-test-renderer/src/__tests__/ReactTestRenderer-test.internal.js
@@ -1021,4 +1021,27 @@ describe('ReactTestRenderer', () => {
     ReactNoop.flush();
     ReactTestRenderer.create();
   });
+
+  describe('act', () => {
+    it('works', () => {
+      function App(props) {
+        React.useEffect(() => {
+          props.callback();
+        });
+        return null;
+      }
+      let called = false;
+      ReactTestRenderer.act(() => {
+        ReactTestRenderer.create(
+           {
+              called = true;
+            }}
+          />,
+        );
+      });
+
+      expect(called).toBe(true);
+    });
+  });
 });